mahendra0120 commited on
Commit
70f9b0c
·
verified ·
1 Parent(s): 9b736b4

Delete ASP/qwen_agents/.ipynb_checkpoints

Browse files
ASP/qwen_agents/.ipynb_checkpoints/model_utils-checkpoint.py DELETED
@@ -1,738 +0,0 @@
1
- """
2
- model_utils.py
3
- ─────────────────────────────────────────────────────────────────
4
- Shared helper that wraps a local Qwen3-VL checkpoint (loaded via
5
- unsloth's FastVisionModel, full precision) as a pydantic-ai `Model`
6
- so both agents can plug it into `Agent(model=...)`.
7
-
8
- This has nothing to do with MCP or A2A — it is pure local inference.
9
- Both forensic_agent.py and profiler_agent.py import `make_model`
10
- from here instead of duplicating the huggingface_hub/unsloth
11
- loading code.
12
-
13
- Tool calling: since this is a raw local-generation backend (not a
14
- hosted API with native function-calling), tool calls are implemented
15
- via a simple text protocol — the model is told to emit
16
- `<tool_call>{"name": ..., "arguments": {...}}</tool_call>` when it
17
- wants to invoke a tool (e.g. the Profiler agent's MCP filesystem
18
- `read_file`). We detect that block in the generated text, parse it,
19
- and return a real pydantic-ai `ToolCallPart` so the framework
20
- actually executes the tool and loops generation with the result.
21
- """
22
-
23
- from __future__ import annotations
24
- try: # apply the runtime defaults even if the caller did not
25
- import env_defaults # noqa: F401
26
- except ImportError:
27
- pass
28
- import asyncio
29
- import json
30
- import logging
31
- import os
32
- import re
33
- import uuid
34
- from functools import lru_cache
35
- from pathlib import Path
36
- from typing import Optional
37
- from dotenv import load_dotenv
38
- from huggingface_hub import snapshot_download
39
- from transformers import AutoProcessor
40
- from pydantic_ai.messages import (
41
- BinaryContent,
42
- DocumentUrl,
43
- ImageUrl,
44
- ModelMessage,
45
- ModelRequest,
46
- ModelResponse,
47
- SystemPromptPart,
48
- TextPart,
49
- ToolCallPart,
50
- ToolReturnPart,
51
- UserPromptPart,
52
- )
53
- from pydantic_ai.models.function import AgentInfo, FunctionModel
54
-
55
- load_dotenv(Path(__file__).resolve().parent.parent / ".env")
56
-
57
- log = logging.getLogger("model_utils")
58
-
59
- hf_home = os.getenv("HF_HOME") or "/workspace/hf_cache"
60
- os.environ["HF_HOME"] = hf_home
61
-
62
- log.info(f"Using HF_HOME: {hf_home}")
63
-
64
- # Regex for detecting a tool-call block in the model's raw generated text.
65
- _TOOL_CALL_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
66
-
67
-
68
- def _download(repo_id: str) -> str:
69
- """
70
- Explicitly pull a model repo down via huggingface_hub before loading
71
- it, rather than relying on `from_pretrained`'s implicit download.
72
- Returns the local snapshot directory. Safe to call repeatedly —
73
- huggingface_hub no-ops (just verifies the cache) if already present.
74
- """
75
- return snapshot_download(repo_id=repo_id, token=os.getenv("HF_TOKEN"))
76
-
77
-
78
- def _load_with_transformers(local_path: str, processor_path: Optional[str], load_in_4bit: bool):
79
- """
80
- Load with plain transformers (no unsloth): bf16 on the GPU, SDPA attention.
81
- Adapter repos (adapter_config.json) are loaded onto their base model and merged.
82
- Set ADAPTER_BASE_MODEL_ID to override the base; ATTN_IMPLEMENTATION=eager to rule out attention kernels.
83
- """
84
- import torch
85
- from transformers import AutoModelForImageTextToText
86
-
87
- kwargs = dict(
88
- dtype=torch.bfloat16,
89
- device_map="cuda" if torch.cuda.is_available() else None,
90
- attn_implementation=os.getenv("ATTN_IMPLEMENTATION", "sdpa"),
91
- )
92
- if load_in_4bit:
93
- from transformers import BitsAndBytesConfig
94
- kwargs["quantization_config"] = BitsAndBytesConfig(
95
- load_in_4bit=True,
96
- bnb_4bit_compute_dtype=torch.bfloat16,
97
- bnb_4bit_quant_type="nf4",
98
- )
99
-
100
- root = Path(local_path)
101
- adapter_cfg_file = root / "adapter_config.json"
102
- base_path = None
103
- if adapter_cfg_file.exists():
104
- base_id = os.getenv("ADAPTER_BASE_MODEL_ID") or json.loads(
105
- adapter_cfg_file.read_text()
106
- ).get("base_model_name_or_path")
107
- if not base_id:
108
- raise RuntimeError(
109
- f"{local_path!r} is an adapter repo but adapter_config.json names no "
110
- f"base model; set ADAPTER_BASE_MODEL_ID."
111
- )
112
- if "bnb-4bit" in base_id and not load_in_4bit:
113
- log.warning(
114
- f"adapter base {base_id!r} is a pre-quantized bnb-4bit repo; if loading "
115
- f"or merging fails, set ADAPTER_BASE_MODEL_ID to a full-precision base."
116
- )
117
- base_path = base_id if Path(base_id).exists() else _download(base_id)
118
- log.info(f"Adapter repo detected; loading base {base_id!r} then applying {local_path!r}")
119
- model = AutoModelForImageTextToText.from_pretrained(base_path, **kwargs)
120
- from peft import PeftModel
121
- model = PeftModel.from_pretrained(model, local_path)
122
- if not load_in_4bit:
123
- try:
124
- model = model.merge_and_unload()
125
- except Exception as e:
126
- log.warning(f"Could not merge adapter ({e!r}); running with the adapter attached.")
127
- else:
128
- model = AutoModelForImageTextToText.from_pretrained(local_path, **kwargs)
129
- model.eval()
130
-
131
- def _is_real_vlm_processor(proc) -> bool:
132
- return proc is not None and hasattr(proc, "image_processor")
133
-
134
- candidates = [processor_path] if processor_path else [local_path] + ([base_path] if base_path else [])
135
- processor = None
136
- errors = []
137
- for src in candidates:
138
- try:
139
- proc = AutoProcessor.from_pretrained(src)
140
- except Exception as e:
141
- errors.append(f"{src}: {e!r}")
142
- continue
143
- if _is_real_vlm_processor(proc):
144
- processor = proc
145
- break
146
- errors.append(f"{src}: loaded {type(proc).__name__} without an image_processor")
147
- if processor is None:
148
- raise RuntimeError(
149
- f"Model loaded, but no usable image processor was found. Tried: {errors}. "
150
- f"Pass processor_path (e.g. 'Qwen/Qwen3-VL-8B-Thinking') to make_model()."
151
- )
152
- return model, processor
153
-
154
-
155
- @lru_cache(maxsize=4)
156
- def _load(model_id: str, adapter_path: Optional[str], processor_path: Optional[str], load_in_4bit: bool):
157
- """Load (and cache) the model + processor for a given model_id, via unsloth.
158
-
159
- `load_in_4bit` is part of the cache key (not just a call-time knob)
160
- so a model requested once in 4-bit and once in full precision are
161
- correctly treated as two distinct loads rather than silently
162
- reusing whichever was loaded first.
163
- """
164
- local_path = _download(model_id)
165
- model, processor = _load_with_transformers(local_path, processor_path, load_in_4bit)
166
-
167
- if adapter_path:
168
- model.load_adapter(adapter_path)
169
-
170
- return model, processor
171
-
172
-
173
- def _extract_chat_messages(messages: list[ModelMessage]) -> list[dict]:
174
- """
175
- Turn pydantic-ai's message history into Qwen chat-template messages.
176
-
177
- Handles four things:
178
- - System prompts (as list-of-content-parts, required by the
179
- multimodal processor's apply_chat_template).
180
- - User prompts — text + image pieces, merged into ONE user turn
181
- per ModelRequest (fasta2a's bridge splits text/file into
182
- separate UserPromptParts, but the chat template needs them
183
- combined so the model actually associates the prompt with the
184
- image).
185
- - Prior assistant turns (ModelResponse) — plain text answers, or
186
- a tool call the assistant previously made.
187
- - Tool results (ToolReturnPart, inside a later ModelRequest) —
188
- folded into a user-role text message describing what the tool
189
- returned, so the model can see it and continue reasoning.
190
- """
191
- chat: list[dict] = []
192
-
193
- for msg in messages:
194
- if isinstance(msg, ModelRequest):
195
- user_content_items: list[dict] = []
196
-
197
- for part in msg.parts:
198
- if isinstance(part, SystemPromptPart):
199
- chat.append(
200
- {"role": "system", "content": [{"type": "text", "text": part.content}]}
201
- )
202
-
203
- elif isinstance(part, UserPromptPart):
204
- raw = part.content
205
- pieces = raw if isinstance(raw, list) else [raw]
206
- for piece in pieces:
207
- if isinstance(piece, str):
208
- user_content_items.append({"type": "text", "text": piece})
209
- elif isinstance(piece, ImageUrl):
210
- user_content_items.append({"type": "image", "image": piece.url})
211
- elif isinstance(piece, BinaryContent) and piece.is_image:
212
- user_content_items.append({"type": "image", "image": piece.data})
213
- elif isinstance(piece, DocumentUrl):
214
- # fasta2a's bridge misclassifies our image file parts as
215
- # generic DocumentUrl (media_type detection gap on its
216
- # side) — every file we send through ImagePart is
217
- # actually an image, so treat DocumentUrl as one here.
218
- user_content_items.append({"type": "image", "image": piece.url})
219
-
220
- elif isinstance(part, ToolReturnPart):
221
- tool_text = (
222
- f"Tool '{part.tool_name}' returned:\n"
223
- f"{part.model_response_str()}"
224
- )
225
- user_content_items.append({"type": "text", "text": tool_text})
226
-
227
- if user_content_items:
228
- chat.append({"role": "user", "content": user_content_items})
229
-
230
- elif isinstance(msg, ModelResponse):
231
- # A prior turn from this same model — either it answered with
232
- # plain text, or it made a tool call. Represent both as an
233
- # assistant turn so the model has continuity across the
234
- # tool-call -> tool-result -> follow-up-generation loop.
235
- text_parts = []
236
- for part in msg.parts:
237
- if isinstance(part, TextPart):
238
- text_parts.append(part.content)
239
- elif isinstance(part, ToolCallPart):
240
- call_repr = json.dumps({"name": part.tool_name, "arguments": part.args})
241
- text_parts.append(f"<tool_call>{call_repr}</tool_call>")
242
- if text_parts:
243
- chat.append(
244
- {"role": "assistant", "content": [{"type": "text", "text": "\n".join(text_parts)}]}
245
- )
246
-
247
- return chat
248
-
249
-
250
- def _build_tool_instructions(agent_info: AgentInfo) -> Optional[str]:
251
- """
252
- Describe the agent's available MCP/function tools to the model in
253
- plain text, along with the exact text protocol it must use to call
254
- one. Returns None if the agent has no tools at all.
255
- """
256
- tools = list(agent_info.function_tools or [])
257
- if not tools:
258
- return None
259
-
260
- tool_lines = []
261
- for t in tools:
262
- schema = json.dumps(t.parameters_json_schema)
263
- tool_lines.append(f"- {t.name}: {t.description}\n Arguments schema: {schema}")
264
-
265
- return (
266
- "You have access to the following tools:\n\n"
267
- + "\n".join(tool_lines)
268
- + "\n\nTo call a tool, respond with ONLY this and nothing else "
269
- "(no prose, no explanation, exact format):\n"
270
- '<tool_call>{"name": "<tool_name>", "arguments": {<matching the schema above>}}</tool_call>\n\n'
271
- "After a tool result is shown to you, continue and give your real "
272
- "answer as plain text (no tool_call tag) once you have what you need."
273
- )
274
-
275
-
276
- def _build_chat_and_inputs(
277
- model,
278
- processor,
279
- model_id: str,
280
- messages: list[ModelMessage],
281
- agent_info: AgentInfo,
282
- reference_images: Optional[list],
283
- ):
284
- """
285
- Shared preamble for both the blocking (`_run`) and streaming
286
- (`_stream_run`) generation paths: build the Qwen chat-template
287
- turns (reference images, tool instructions, output-schema hint)
288
- and tokenize them. Returns (inputs, tokenizer).
289
- """
290
- chat = _extract_chat_messages(messages)
291
-
292
- is_first_turn = not any(isinstance(m, ModelResponse) for m in messages)
293
- if reference_images and is_first_turn:
294
- ref_content = [
295
- {"type": "text", "text": "Reference material (read this before analyzing the case):"}
296
- ]
297
- for img_path in reference_images:
298
- ref_content.append({"type": "image", "image": str(img_path)})
299
- chat.insert(0, {"role": "user", "content": ref_content})
300
- log.info(f"[{model_id}] Attached {len(reference_images)} reference image(s)")
301
-
302
- has_image = any(
303
- isinstance(m.get("content"), list)
304
- and any(c.get("type") == "image" for c in m["content"])
305
- for m in chat
306
- if m["role"] == "user"
307
- )
308
- log.info(f"[{model_id}] Built chat with {len(chat)} turns (image attached: {has_image})")
309
-
310
- tool_instructions = _build_tool_instructions(agent_info)
311
- if tool_instructions:
312
- chat.append(
313
- {"role": "system", "content": [{"type": "text", "text": tool_instructions}]}
314
- )
315
- log.info(
316
- f"[{model_id}] {len(agent_info.function_tools)} tool(s) available: "
317
- f"{[t.name for t in agent_info.function_tools]}"
318
- )
319
-
320
- if agent_info.output_tools:
321
- schema_hint = json.dumps([t.parameters_json_schema for t in agent_info.output_tools])
322
- chat.append(
323
- {
324
- "role": "system",
325
- "content": [{
326
- "type": "text",
327
- "text": (
328
- "Respond with ONLY a single JSON object matching this "
329
- f"schema, no prose, no markdown fences: {schema_hint}"
330
- ),
331
- }],
332
- }
333
- )
334
-
335
- inputs = processor.apply_chat_template(
336
- chat,
337
- tokenize=True,
338
- add_generation_prompt=True,
339
- return_dict=True,
340
- return_tensors="pt",
341
- ).to(model.device)
342
-
343
- tokenizer = getattr(processor, "tokenizer", processor)
344
- return inputs, tokenizer
345
-
346
-
347
- def _strip_thinking(raw_text: str) -> str:
348
- """
349
- Remove a <think>...</think> reasoning block, returning only what
350
- comes after it. Falls back to stripping from an unclosed <think>
351
- onward (rather than leaking raw chain-of-thought) if the model
352
- ran out of budget before emitting the closing tag.
353
- """
354
- if "</think>" in raw_text:
355
- return raw_text.split("</think>", 1)[1]
356
- if "<think>" in raw_text:
357
- # Unclosed — the model was still "thinking" when generation
358
- # stopped. Better to show nothing than leak raw reasoning.
359
- return raw_text.split("<think>", 1)[0]
360
- return raw_text
361
-
362
-
363
- def _strip_special_tokens(text: str, tokenizer) -> str:
364
- """For a COMPLETE final text: remove special tokens and trim whitespace."""
365
- return _replace_special_tokens(text, tokenizer).strip()
366
-
367
-
368
- def _replace_special_tokens(text: str, tokenizer) -> str:
369
- """
370
- For a streaming DELTA: remove special tokens only, WITHOUT
371
- trimming whitespace — a delta chunk's leading/trailing space is
372
- real inter-word spacing once concatenated with neighboring
373
- chunks, not padding to discard (unlike the final-text case).
374
- """
375
- for special_tok in getattr(tokenizer, "all_special_tokens", []):
376
- text = text.replace(special_tok, "")
377
- return text
378
-
379
-
380
- def make_model(
381
- model_id: str,
382
- *,
383
- adapter_path: Optional[str] = None,
384
- processor_path: Optional[str] = None,
385
- temperature: float = 0.15,
386
- max_new_tokens: int = 6144,
387
- reference_images: Optional[list] = None,
388
- load_in_4bit: bool = False,
389
- ) -> FunctionModel:
390
- """
391
- Build a pydantic-ai `Model` backed by a local Qwen3-VL checkpoint,
392
- loaded via unsloth's FastVisionModel. Pass `load_in_4bit=True` (or
393
- set the per-agent env var — see forensic_agent.py / profiler_agent.py)
394
- to cut VRAM usage roughly 4x; leave False for full precision if you
395
- have the VRAM headroom for both agents' models at once.
396
-
397
- Returns a `FunctionModel` (a model driven by plain Python
398
- callables) so `Agent(model=make_model(...))` works exactly like it
399
- would with a hosted-API model, but everything runs on-device with
400
- no network call. Both a blocking path (`_run`, used by
401
- `agent.run()`) and a streaming path (`_stream_run`, used by
402
- `agent.run_stream()` and by fasta2a's `message/stream` SSE
403
- endpoint) are provided — see `_stream_run`'s docstring for details,
404
- including its guarantee to always yield at least one item even on
405
- failure (pydantic-ai otherwise raises `ValueError: Stream function
406
- must return at least one item`, which used to crash the whole A2A
407
- task).
408
-
409
- `max_new_tokens` defaults to 6144 rather than a smaller number
410
- specifically because an agent with a large embedded system prompt
411
- (e.g. the Profiler's full skill file) doing real step-by-step
412
- `<think>` reasoning can otherwise exhaust its budget before ever
413
- reaching a final answer — raise it further per-agent (see
414
- profiler_agent.py) if that still happens; `_stream_run` logs a
415
- clear warning naming this exact cause when it does.
416
-
417
- Both paths strip a <think>...</think> reasoning block so only the
418
- final answer is ever returned to the caller — see `_strip_thinking`.
419
-
420
- Supports real MCP/tool execution via a simple text-based tool-call
421
- protocol (see `_build_tool_instructions`/`_TOOL_CALL_RE`) — if the
422
- agent has tools attached (via `toolsets=[...]`), the model is told
423
- how to invoke them, and a detected tool-call is turned into a real
424
- `ToolCallPart` so pydantic-ai's graph actually runs the tool and
425
- loops generation with its result.
426
- """
427
-
428
- def _run(messages: list[ModelMessage], agent_info: AgentInfo) -> ModelResponse:
429
- model, processor = _load(model_id, adapter_path, processor_path, load_in_4bit)
430
- inputs, tokenizer = _build_chat_and_inputs(
431
- model, processor, model_id, messages, agent_info, reference_images
432
- )
433
-
434
- generated_ids = model.generate(
435
- **inputs,
436
- max_new_tokens=max_new_tokens,
437
- do_sample=temperature > 0,
438
- temperature=max(temperature, 1e-5),
439
- repetition_penalty=1.15,
440
- # Stop generation the moment a complete tool-call block is
441
- # emitted, instead of burning the rest of the token budget
442
- # re-generating near-identical repeats of the same call.
443
- stop_strings=["</tool_call>"],
444
- tokenizer=tokenizer,
445
- )
446
- trimmed = [
447
- out_ids[len(in_ids):]
448
- for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
449
- ]
450
-
451
- # Decode WITH special tokens first — if skip_special_tokens=True is
452
- # used here, tokenizers that register </think> as an actual special
453
- # token will silently strip the tag itself during decoding, which
454
- # means a string search for it below never finds it and the raw
455
- # chain-of-thought leaks straight into the final answer. Instead we
456
- # decode with tags intact, split on the visible tag, and only THEN
457
- # strip out any remaining special-token text from what's left.
458
- raw_text = processor.batch_decode(
459
- trimmed, skip_special_tokens=False, clean_up_tokenization_spaces=False
460
- )[0]
461
-
462
- output_text = _strip_special_tokens(_strip_thinking(raw_text), tokenizer)
463
-
464
- # Detect a tool-call block. If present, return a real ToolCallPart
465
- # so pydantic-ai actually executes the corresponding tool (e.g. the
466
- # Profiler agent's MCP read_file) instead of just leaving it as
467
- # inert text the model happened to type.
468
- match = _TOOL_CALL_RE.search(output_text)
469
- if match:
470
- try:
471
- call_data = json.loads(match.group(1))
472
- tool_name = call_data["name"]
473
- tool_args = call_data.get("arguments", {})
474
- tool_call_id = str(uuid.uuid4())
475
- log.info(f"[{model_id}] Tool call detected -> {tool_name}({tool_args})")
476
- return ModelResponse(
477
- parts=[ToolCallPart(tool_name=tool_name, args=tool_args, tool_call_id=tool_call_id)]
478
- )
479
- except (json.JSONDecodeError, KeyError) as e:
480
- log.info(f"[{model_id}] Malformed tool_call block, treating as plain text: {e}")
481
-
482
- log.info(f"[{model_id}] Final text answer ({len(output_text)} chars), no tool call")
483
- return ModelResponse(parts=[TextPart(content=output_text)])
484
-
485
- async def _stream_run(messages: list[ModelMessage], agent_info: AgentInfo):
486
- """
487
- Streaming counterpart to `_run`. Yields plain `str` deltas
488
- (new text only, not the accumulated total — matching
489
- FunctionModel's `handle_text_delta` protocol) representing
490
- ONLY the final-answer portion of the generation.
491
-
492
- Thinking-token handling: everything from the start of
493
- generation up through the closing `</think>` tag is consumed
494
- and DISCARDED here, never yielded — this repo's requirement is
495
- "only the final answer in markdown", not a live view into the
496
- model's reasoning. We buffer silently until we're past
497
- `</think>`, then start yielding real deltas.
498
-
499
- Tool-call handling: pydantic-ai's FunctionModel forbids mixing
500
- plain `str` deltas with `DeltaToolCalls` in a single streamed
501
- response (see fasta2a's stream_function docstring). Since our
502
- tool-call protocol requires the model to emit ONLY a
503
- `<tool_call>...</tool_call>` block with no other prose when
504
- calling a tool, we can safely decide which "mode" this
505
- response is in from its first few post-</think> characters:
506
- if they look like the start of a tool call, we suppress all
507
- text streaming and, once generation finishes, yield a single
508
- complete `DeltaToolCalls` item instead; otherwise we stream
509
- real text deltas as they arrive.
510
-
511
- This streams the model's generation in-process AND across the
512
- A2A hop: fasta2a==2.0.1's `message/stream` SSE endpoint relays
513
- exactly what this generator yields to a remote caller in real
514
- time (see A2A_image_delegation_client.py's `stream_task`).
515
-
516
- RELIABILITY GUARANTEE — pydantic-ai's FunctionModel raises
517
- `ValueError: Stream function must return at least one item`
518
- if this generator ever completes without yielding anything at
519
- all, which used to crash the whole A2A task instead of failing
520
- gracefully. The entire body below is wrapped in a `try/except`
521
- specifically so that ANY failure — model loading, chat
522
- template construction, the generation thread crashing or
523
- stalling, or generation legitimately ending before any
524
- final-answer text was produced (e.g. `max_new_tokens` used up
525
- entirely on `<think>` reasoning) — always degrades to exactly
526
- one honest, clearly-labeled fallback message instead of
527
- propagating and taking the task down.
528
- """
529
- import threading
530
- from transformers import TextIteratorStreamer
531
-
532
- buffer = "" # raw text so far (special tokens intact) — used by the fallback below too
533
- past_think = False
534
-
535
- try:
536
- model, processor = _load(model_id, adapter_path, processor_path, load_in_4bit)
537
- inputs, tokenizer = _build_chat_and_inputs(
538
- model, processor, model_id, messages, agent_info, reference_images
539
- )
540
-
541
- streamer = TextIteratorStreamer(
542
- tokenizer, skip_prompt=True, skip_special_tokens=False
543
- )
544
- gen_kwargs = dict(
545
- **inputs,
546
- max_new_tokens=max_new_tokens,
547
- do_sample=temperature > 0,
548
- temperature=max(temperature, 1e-5),
549
- repetition_penalty=1.15,
550
- stop_strings=["</tool_call>"],
551
- tokenizer=tokenizer,
552
- streamer=streamer,
553
- )
554
-
555
- # If model.generate() raises partway through (OOM, a bad
556
- # shape, etc.), it can die without ever calling streamer.end()
557
- # — which would otherwise leave the consumer loop below
558
- # blocked forever on the streamer's queue. Capturing the
559
- # exception here and guaranteeing `.end()` either way turns
560
- # that failure mode from "hangs the whole pipeline" into
561
- # "reported and handled like any other generation failure."
562
- gen_error: list[BaseException] = []
563
-
564
- def _generate():
565
- try:
566
- model.generate(**gen_kwargs)
567
- except BaseException as e: # noqa: BLE001 - deliberately broad; see above
568
- gen_error.append(e)
569
- finally:
570
- streamer.end()
571
-
572
- gen_thread = threading.Thread(target=_generate, daemon=True)
573
- gen_thread.start()
574
-
575
- mode: Optional[str] = None # "text" or "tool_call", decided once, right after </think>
576
- already_yielded_len = 0 # how much of the post-</think> text we've already emitted (text mode only)
577
- yielded_anything = False # pydantic-ai's FunctionModel requires >= 1 yield — see fallback below
578
- TOOL_CALL_PREFIX = "<tool_call>"
579
- sentinel = object()
580
- # A safety net on top of the streamer.end()-in-finally
581
- # guarantee above (not the primary defense): bounds how long
582
- # we'll wait for a single next token before concluding the
583
- # generation thread has gotten stuck some other way.
584
- CHUNK_TIMEOUT_S = 300.0
585
-
586
- while True:
587
- try:
588
- chunk = await asyncio.wait_for(
589
- asyncio.to_thread(next, streamer, sentinel), timeout=CHUNK_TIMEOUT_S
590
- )
591
- except asyncio.TimeoutError:
592
- log.warning(
593
- f"[{model_id}] (stream) No token received for {CHUNK_TIMEOUT_S:.0f}s — "
594
- "treating generation as stalled."
595
- )
596
- gen_error.append(TimeoutError(f"Generation stalled for over {CHUNK_TIMEOUT_S:.0f}s"))
597
- break
598
- if chunk is sentinel:
599
- break
600
- buffer += chunk
601
-
602
- if not past_think:
603
- if "</think>" not in buffer:
604
- continue # still inside <think>...</think> — suppress entirely
605
- past_think = True
606
-
607
- after = buffer.split("</think>", 1)[1]
608
-
609
- if mode is None:
610
- stripped = after.lstrip()
611
- if not stripped:
612
- continue # nothing meaningful yet, keep waiting
613
- if stripped.startswith(TOOL_CALL_PREFIX):
614
- mode = "tool_call"
615
- continue # suppress; handled once the loop ends, below
616
- if TOOL_CALL_PREFIX.startswith(stripped):
617
- continue # ambiguous prefix (e.g. just "<" or "<tool_c") — keep waiting
618
- mode = "text"
619
- already_yielded_len = len(after)
620
- visible = _replace_special_tokens(after, tokenizer)
621
- if visible:
622
- yielded_anything = True
623
- yield visible
624
- continue
625
-
626
- if mode == "text":
627
- new_part = after[already_yielded_len:]
628
- already_yielded_len = len(after)
629
- visible = _replace_special_tokens(new_part, tokenizer)
630
- if visible:
631
- yielded_anything = True
632
- yield visible
633
- # mode == "tool_call": suppress streaming, just keep buffering;
634
- # handled once the loop ends, below.
635
-
636
- gen_thread.join(timeout=10.0)
637
- if gen_thread.is_alive():
638
- # The CHUNK_TIMEOUT_S stall-detection above already gave up
639
- # waiting on this thread's output; don't also block here
640
- # indefinitely if it still hasn't wound down shortly after.
641
- # It's a daemon thread, so leaving it running in the
642
- # background doesn't prevent process shutdown — this just
643
- # avoids turning one stuck generation into a stuck request.
644
- log.warning(
645
- f"[{model_id}] (stream) Generation thread still alive "
646
- "10s after stall/completion was detected — abandoning "
647
- "it and returning the fallback response."
648
- )
649
-
650
- if mode == "tool_call" or (mode is None and "<tool_call>" in buffer):
651
- final_text = _strip_special_tokens(_strip_thinking(buffer), tokenizer)
652
- match = _TOOL_CALL_RE.search(final_text)
653
- if match:
654
- try:
655
- call_data = json.loads(match.group(1))
656
- tool_call_id = str(uuid.uuid4())
657
- from pydantic_ai.models.function import DeltaToolCall
658
- log.info(f"[{model_id}] (stream) Tool call detected -> {call_data.get('name')}")
659
- yield {
660
- 0: DeltaToolCall(
661
- name=call_data["name"],
662
- json_args=json.dumps(call_data.get("arguments", {})),
663
- tool_call_id=tool_call_id,
664
- )
665
- }
666
- return
667
- except (json.JSONDecodeError, KeyError) as e:
668
- log.info(f"[{model_id}] (stream) Malformed tool_call block, falling back to text: {e}")
669
- # Malformed / didn't actually resolve into a tool call after
670
- # all — since we suppressed all along in this branch, we've
671
- # yielded nothing yet, so it's still safe to fall back to a
672
- # single text item without violating the no-mixing rule.
673
- if final_text:
674
- yielded_anything = True
675
- yield final_text
676
-
677
- if not yielded_anything:
678
- # This happens whenever generation ends before any
679
- # post-</think> text was ever yielded, chiefly:
680
- # - max_new_tokens was exhausted while still inside
681
- # <think> (past_think never became True at all) — the
682
- # more elaborate/heavily-prompted an agent's reasoning
683
- # is relative to its token budget, the likelier this
684
- # is (this is what was happening to the Profiler
685
- # agent with its large embedded skill file pushing it
686
- # into long reasoning traces — see PROFILER_MODEL_ID's
687
- # larger `max_new_tokens` in profiler_agent.py).
688
- # - </think> was reached but generation ended before any
689
- # non-whitespace, non-tool-call text followed it.
690
- # - a tool-call block was detected but turned out
691
- # malformed AND stripped down to nothing.
692
- # - the generation thread crashed or stalled (gen_error
693
- # non-empty) before producing anything usable.
694
- # In every case we must still yield exactly one item — but
695
- # NEVER the raw, un-stripped chain-of-thought (that would
696
- # violate the "only the final answer" requirement) — so we
697
- # yield a clear, honest placeholder instead, and log
698
- # loudly so this is easy to diagnose.
699
- if gen_error:
700
- log.warning(f"[{model_id}] (stream) Generation failed: {gen_error[0]!r}")
701
- yield (
702
- "_⚠️ Generation failed before producing an answer "
703
- f"({type(gen_error[0]).__name__}: {gen_error[0]}). Try again._"
704
- )
705
- elif not past_think:
706
- log.warning(
707
- f"[{model_id}] (stream) Hit max_new_tokens ({max_new_tokens}) "
708
- "while still inside <think> — no final answer was produced."
709
- )
710
- yield (
711
- "_⚠️ The model ran out of its generation budget while still "
712
- "reasoning and never produced a final answer. Try again, or "
713
- "increase `max_new_tokens` for this agent._"
714
- )
715
- else:
716
- log.warning(
717
- f"[{model_id}] (stream) Reached </think> but produced no "
718
- "usable final-answer text before generation ended."
719
- )
720
- yield (
721
- "_⚠️ The model finished reasoning but generation ended "
722
- "before it wrote a final answer. Try again, or increase "
723
- "`max_new_tokens` for this agent._"
724
- )
725
-
726
- log.info(f"[{model_id}] (stream) Done ({len(buffer)} raw chars)")
727
-
728
- except Exception as e:
729
- # Catches everything not already handled above: a crash while
730
- # loading the model, building the chat template, or any other
731
- # unexpected failure before/without a yield. Logged with the
732
- # full traceback since this is the case we most want visibility
733
- # into, and — critically — still yields exactly one item so
734
- # fasta2a's AgentWorker never sees an empty stream.
735
- log.exception(f"[{model_id}] (stream) Unhandled error in _stream_run")
736
- yield f"_⚠️ Internal error while generating this agent's response: {type(e).__name__}: {e}._"
737
-
738
- return FunctionModel(_run, stream_function=_stream_run, model_name=model_id)