File size: 7,211 Bytes
d81d76f
7224d8b
 
 
 
d81d76f
 
 
5c6cd95
d81d76f
 
7224d8b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3deb9e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7224d8b
 
 
 
 
 
 
 
 
 
 
 
e3deb9e
 
 
 
 
5c6cd95
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
---
title: Multi-Agent Assistant
emoji: 🤖
colorFrom: purple
colorTo: blue
sdk: gradio
app_file: app.py
pinned: false
sdk_version: 6.26.0
---

# Multi-Agent Assistant

A chat app where a manager agent routes each request to specialist agents,
built with [LangGraph](https://langchain-ai.github.io/langgraph/) and running
on Hugging Face [ZeroGPU](https://huggingface.co/docs/hub/spaces-zerogpu).

## How it works

1. You send a message in the chat box.
2. A **manager agent** reads it and decides what's needed next: web search,
   Python execution, numeric analysis, or "I have enough — finish."
3. The chosen **specialist agent** does its job and reports back to the
   manager, which can loop through multiple specialists before finishing
   (capped at `MAX_TURNS` round-trips to avoid infinite loops).
4. A **finalize** step synthesizes everything the specialists found into one
   answer, which appears in the chat.

```
Gradio UI → Manager agent → {search_agent, code_agent, data_agent} → Manager → Finalize → UI
```

## Specialists

| Agent | Tool | What it does |
|---|---|---|
| `search_agent` | Tavily web search | Looks up current information on the web |
| `code_agent` | Restricted Python subprocess | Writes and runs Python to compute things |
| `data_agent` | Summary statistics | Computes count/sum/mean/min/max on a list of numbers |

The model itself is [Qwen/Qwen2.5-3B-Instruct](https://huggingface.co/Qwen/Qwen2.5-3B-Instruct),
loaded locally and run on a ZeroGPU-allocated GPU per request (see `generate()`
in `agents.py`) — nothing is sent to an external inference API.

## Files

- `app.py` — Gradio UI (chat interface, session state)
- `agents.py` — LangGraph state machine, model loading, tools, prompts
- `requirements.txt` — Python dependencies (torch/gradio/spaces versions are
  managed by the Space's build system and intentionally not pinned here)

## Required secrets

Set these under **Settings → Variables and secrets**:

| Secret | Used for |
|---|---|
| `TAVILY_API_KEY` | Required for `search_agent`. Get a free key at [tavily.com](https://tavily.com). |
| `HF_TOKEN` | Optional. Speeds up model downloads and avoids Hub rate limits. |

Without `TAVILY_API_KEY`, every other feature still works — `search_agent`
just returns a "Search unavailable" message instead of crashing the app.

## Hardware

This Space requires **ZeroGPU** hardware (not CPU-basic), since `agents.py`
runs local model inference inside a function decorated with `@spaces.GPU`.

## Technical architecture

### State (`AgentState`, LangGraph)
A `TypedDict` threaded through every node: `messages` (accumulated via
`add_messages`), `next` (the manager's routing decision), `task` (the
original request), `results` (a scratch dict specialists write into), and
`turn_count` (see `MAX_TURNS` below).

### Control flow

```mermaid
sequenceDiagram
    participant U as User
    participant M as Manager
    participant S as Specialist
    U->>M: task
    loop up to MAX_TURNS
        M->>M: generate() → route decision
        alt route = specialist
            M->>S: task
            S->>S: generate() → tool call or final
            S-->>M: result written to state.results
        else route = finish
            M->>M: break loop
        end
    end
    M->>M: finalize (synthesize results)
    M-->>U: answer
```

`MAX_TURNS` (default 6, in `agents.py`) hard-caps manager↔specialist
round-trips in code — not just via LangGraph's `recursion_limit`, which is a
blunter backstop covering every node hop including `finalize`. Once
`turn_count` exceeds `MAX_TURNS`, `manager_node` skips the model call
entirely and force-routes to `finalize`, so a flip-flopping manager can't
burn extra inference calls on its way out.

### Model layer (local inference on ZeroGPU)
- `_model`/`_tokenizer` are loaded once at import time, on CPU.
- `generate(messages, max_new_tokens)` is the single inference entry point
  used by every node (manager, all three specialists, finalize). It's
  decorated with `@spaces.GPU(duration=60)`: ZeroGPU attaches a GPU only for
  the duration of this call, then releases it — no dedicated GPU sits idle
  between requests.
- Inside `generate()`: model moved to `cuda`, prompt built via
  `apply_chat_template(..., return_dict=True)` (the explicit dict form,
  to avoid ambiguity between transformers versions that return a raw
  tensor vs. a `BatchEncoding` — this was the root cause of an earlier
  `AttributeError` that only reproduced on this Space's installed version),
  greedy decoding (`do_sample=False`).
- All exceptions are caught **inside** `generate()` and returned as a
  `{"error": ...}` string rather than left to propagate across the
  `@spaces.GPU` process boundary — exceptions crossing that boundary raw
  can arrive mangled in the caller (observed in practice: a real
  `AttributeError` surfaced to the caller as an opaque
  `KeyError('AttributeError')`). Catching locally preserves the real
  exception type/message and logs the full traceback.

### Manual tool-calling protocol
Local open-weight models don't expose a native function-calling API the way
hosted inference endpoints do, so tool use is hand-rolled:
1. Each specialist's system prompt lists its tool's name, description, and
   argument schema.
2. The model is asked to respond with **only** JSON — either
   `{"tool": "...", "args": {...}}` or `{"final": "..."}`.
3. The node parses that JSON, invokes the named tool directly if present,
   then makes one follow-up `generate()` call asking the model to
   summarize the tool's raw output.
4. Malformed/non-JSON responses fall back to using the model's raw text as
   the summary, rather than failing the turn.

### Configuration surface

| Constant (`agents.py`) | Default | Effect |
|---|---|---|
| `MODEL_ID` | `Qwen/Qwen2.5-3B-Instruct` | Swap for a larger model if quality is the bottleneck |
| `MAX_TURNS` | 6 | Manager↔specialist round-trip cap |
| `@spaces.GPU(duration=60)` | 60s | Per-call GPU allocation window — bump if generation runs long |

## Known limitations

- `run_python` executes in a locked-down subprocess (restricted builtins,
  5s timeout, isolated interpreter mode) — a reasonable starting point, but
  **not** a real security sandbox. For untrusted input in production, use a
  dedicated sandbox such as E2B Code Interpreter or Modal Sandboxes instead.
- Specialists use a manually-parsed JSON tool-call protocol rather than a
  native function-calling API, since local open models don't expose one the
  way hosted inference endpoints do. Malformed JSON from the model falls
  back to using its raw text response.
- `MAX_TURNS` (in `agents.py`) hard-caps manager↔specialist round-trips per
  request to prevent runaway routing loops.
- **ZeroGPU cold starts.** The first request after the Space idles and
  restarts pays model-load latency. If generation routinely runs long
  (bigger model, longer `max_new_tokens`), `duration=60` may need increasing.
- **Single shared model instance.** `_model`/`_tokenizer` are process-wide
  globals; concurrent requests from different users serialize through the
  same weights (ZeroGPU allocates the GPU per call, not per user).