abstractagent 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,31 @@
1
- """AbstractRuntime adapter for ReAct-like agents."""
1
+ """AbstractRuntime adapter for canonical ReAct agents.
2
+
3
+ This adapter implements a deterministic ReAct loop:
4
+
5
+ init → reason → parse → (act → observe → reason)* → done
6
+
7
+ Policy (for now):
8
+ - Do NOT truncate ReAct loop context (history/scratchpad).
9
+ - Do NOT cap tool-steps to tiny token budgets.
10
+ - Do NOT require "FINAL:" markers or other termination hacks.
11
+
12
+ The loop continues whenever the model emits tool calls.
13
+ It ends only when the model emits **no tool calls** and provides an answer.
14
+ """
2
15
 
3
16
  from __future__ import annotations
4
17
 
5
18
  import hashlib
6
19
  import json
20
+ import re
7
21
  from typing import Any, Callable, Dict, List, Optional
8
22
 
9
23
  from abstractcore.tools import ToolCall
10
24
  from abstractruntime import Effect, EffectType, RunState, StepPlan, WorkflowSpec
11
25
  from abstractruntime.core.vars import ensure_limits, ensure_namespaces
12
26
 
27
+ from .generation_params import runtime_llm_params
28
+ from .media import extract_media_from_context
13
29
  from ..logic.react import ReActLogic
14
30
 
15
31
 
@@ -29,20 +45,60 @@ def _new_message(
29
45
 
30
46
  timestamp = datetime.now(timezone.utc).isoformat()
31
47
 
48
+ import uuid
49
+
50
+ meta = dict(metadata or {})
51
+ meta.setdefault("message_id", f"msg_{uuid.uuid4().hex}")
52
+
32
53
  return {
33
54
  "role": role,
34
55
  "content": content,
35
56
  "timestamp": timestamp,
36
- "metadata": metadata or {},
57
+ "metadata": meta,
37
58
  }
38
59
 
39
60
 
40
- def ensure_react_vars(run: RunState) -> tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any], Dict[str, Any], Dict[str, Any]]:
41
- """Ensure namespaced vars exist and migrate legacy flat keys in-place.
61
+ def _new_assistant_message_with_tool_calls(
62
+ ctx: Any,
63
+ *,
64
+ content: str,
65
+ tool_calls: List[ToolCall],
66
+ metadata: Optional[Dict[str, Any]] = None,
67
+ ) -> Dict[str, Any]:
68
+ """Create an assistant message that preserves tool call metadata for OpenAI transcripts."""
69
+
70
+ msg = _new_message(ctx, role="assistant", content=content, metadata=metadata)
71
+
72
+ tc_payload: list[dict[str, Any]] = []
73
+ for i, tc in enumerate(tool_calls):
74
+ if not isinstance(tc, ToolCall):
75
+ continue
76
+ name = str(tc.name or "").strip()
77
+ if not name:
78
+ continue
79
+ call_id = tc.call_id
80
+ call_id_str = str(call_id).strip() if call_id is not None else ""
81
+ if not call_id_str:
82
+ call_id_str = f"call_{i+1}"
83
+ args = tc.arguments if isinstance(tc.arguments, dict) else {}
84
+ tc_payload.append(
85
+ {
86
+ "type": "function",
87
+ "id": call_id_str,
88
+ "function": {"name": name, "arguments": json.dumps(args, ensure_ascii=False)},
89
+ }
90
+ )
91
+
92
+ if tc_payload:
93
+ msg["tool_calls"] = tc_payload
94
+ return msg
95
+
96
+
97
+ def ensure_react_vars(
98
+ run: RunState,
99
+ ) -> tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any], Dict[str, Any], Dict[str, Any]]:
100
+ """Ensure namespaced vars exist and migrate legacy flat keys in-place."""
42
101
 
43
- Returns:
44
- Tuple of (context, scratchpad, runtime_ns, temp, limits) dicts.
45
- """
46
102
  ensure_namespaces(run.vars)
47
103
  limits = ensure_limits(run.vars)
48
104
  context = run.vars["context"]
@@ -70,6 +126,9 @@ def ensure_react_vars(run: RunState) -> tuple[Dict[str, Any], Dict[str, Any], Di
70
126
  if not isinstance(runtime_ns.get("inbox"), list):
71
127
  runtime_ns["inbox"] = []
72
128
 
129
+ if not isinstance(scratchpad.get("cycles"), list):
130
+ scratchpad["cycles"] = []
131
+
73
132
  iteration = scratchpad.get("iteration")
74
133
  if not isinstance(iteration, int):
75
134
  try:
@@ -85,10 +144,13 @@ def ensure_react_vars(run: RunState) -> tuple[Dict[str, Any], Dict[str, Any], Di
85
144
  scratchpad["max_iterations"] = int(max_iterations)
86
145
  except (TypeError, ValueError):
87
146
  scratchpad["max_iterations"] = 25
88
-
89
147
  if scratchpad["max_iterations"] < 1:
90
148
  scratchpad["max_iterations"] = 1
91
149
 
150
+ used_tools = scratchpad.get("used_tools")
151
+ if not isinstance(used_tools, bool):
152
+ scratchpad["used_tools"] = bool(used_tools) if used_tools is not None else False
153
+
92
154
  return context, scratchpad, runtime_ns, temp, limits
93
155
 
94
156
 
@@ -99,10 +161,470 @@ def _compute_toolset_id(tool_specs: List[Dict[str, Any]]) -> str:
99
161
  return f"ts_{digest}"
100
162
 
101
163
 
164
+ def _tool_call_signature(name: str, args: Any) -> str:
165
+ def _abbrev(v: Any, *, max_chars: int = 140) -> str:
166
+ if v is None:
167
+ return ""
168
+ s = str(v)
169
+ if len(s) <= max_chars:
170
+ return s
171
+ return f"{s[: max(0, max_chars - 1)]}…"
172
+
173
+ def _hash_str(s: str) -> str:
174
+ try:
175
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()[:12]
176
+ except Exception:
177
+ return "sha256_err"
178
+
179
+ n = str(name or "").strip() or "tool"
180
+ if not isinstance(args, dict) or not args:
181
+ return f"{n}()"
182
+
183
+ # Special-case common large-argument tools so the system prompt doesn't explode.
184
+ if n == "write_file":
185
+ fp = args.get("file_path") if isinstance(args.get("file_path"), str) else args.get("path")
186
+ mode = args.get("mode") if isinstance(args.get("mode"), str) else "w"
187
+ content = args.get("content")
188
+ if isinstance(content, str):
189
+ tag = f"<str len={len(content)} sha256={_hash_str(content)}>"
190
+ else:
191
+ tag = "<str len=0>"
192
+ return f"write_file(file_path={_abbrev(fp)!r}, mode={_abbrev(mode)!r}, content={tag})"
193
+
194
+ if n == "edit_file":
195
+ fp = args.get("file_path") if isinstance(args.get("file_path"), str) else args.get("path")
196
+ edits = args.get("edits")
197
+ n_edits = len(edits) if isinstance(edits, list) else 0
198
+ return f"edit_file(file_path={_abbrev(fp)!r}, edits={n_edits})"
199
+
200
+ if n == "fetch_url":
201
+ url = args.get("url")
202
+ include_full = args.get("include_full_content")
203
+ return f"fetch_url(url={_abbrev(url)!r}, include_full_content={include_full})"
204
+
205
+ if n == "web_search":
206
+ q = args.get("query")
207
+ num = args.get("num_results")
208
+ return f"web_search(query={_abbrev(q)!r}, num_results={num})"
209
+
210
+ if n == "execute_command":
211
+ cmd = args.get("command")
212
+ return f"execute_command(command={_abbrev(cmd, max_chars=220)!r})"
213
+
214
+ # Generic, but bounded: hash long strings to avoid leaking large blobs into the prompt.
215
+ summarized: Dict[str, Any] = {}
216
+ for k, v in args.items():
217
+ if isinstance(v, str) and len(v) > 160:
218
+ summarized[str(k)] = f"<str len={len(v)} sha256={_hash_str(v)}>"
219
+ else:
220
+ summarized[str(k)] = v
221
+ try:
222
+ arg_str = json.dumps(summarized, ensure_ascii=False, sort_keys=True)
223
+ except Exception:
224
+ arg_str = str(summarized)
225
+ arg_str = _abbrev(arg_str, max_chars=260)
226
+ return f"{n}({arg_str})"
227
+
228
+
229
+ def _tool_call_fingerprint(name: str, args: Any) -> str:
230
+ """Return a stable, bounded fingerprint for tool-call repeat detection.
231
+
232
+ Important: do not embed large string blobs (file contents / web pages) in the fingerprint.
233
+ """
234
+
235
+ def _hash_str(s: str) -> str:
236
+ try:
237
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
238
+ except Exception:
239
+ return "sha256_err"
240
+
241
+ def _canon(v: Any) -> Any:
242
+ if v is None or isinstance(v, (bool, int, float)):
243
+ return v
244
+ if isinstance(v, str):
245
+ if len(v) <= 200:
246
+ return v
247
+ return {"_type": "str", "len": len(v), "sha256": _hash_str(v)[:16]}
248
+ if isinstance(v, list):
249
+ return [_canon(x) for x in v[:25]]
250
+ if isinstance(v, dict):
251
+ out: Dict[str, Any] = {}
252
+ for k in sorted(v.keys(), key=lambda x: str(x)):
253
+ out[str(k)] = _canon(v.get(k))
254
+ return out
255
+ return {"_type": type(v).__name__}
256
+
257
+ payload = {"name": str(name or "").strip(), "args": _canon(args if isinstance(args, dict) else {})}
258
+ try:
259
+ raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
260
+ except Exception:
261
+ raw = str(payload)
262
+ try:
263
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
264
+ except Exception:
265
+ return "fingerprint_err"
266
+
267
+
268
+ _FINALISH_RE = re.compile(
269
+ r"(?i)\b(final answer|here is|here['’]s|here are|below is|below are|done|completed|in summary|summary|result)\b"
270
+ )
271
+
272
+ _WAITING_RE = re.compile(
273
+ r"(?i)\b("
274
+ r"let me know|your next step|what would you like|tell me|"
275
+ r"i can help|i'm ready|i am ready|"
276
+ r"i'll wait|i will wait|waiting for|"
277
+ r"no tool calls?"
278
+ r")\b"
279
+ )
280
+
281
+ _DEFERRED_ACTION_INTENT_RE = re.compile(
282
+ # Only treat as "missing tool calls" when the model *commits to acting*
283
+ # (first-person intent) rather than providing a final answer.
284
+ r"(?i)\b(i will|i['’]?ll|let me|i am going to|i['’]?m going to|i need to)\b"
285
+ )
286
+
287
+ _DEFERRED_ACTION_VERB_RE = re.compile(
288
+ # Verbs that typically imply external actions (tools/files/web/edits).
289
+ r"(?i)\b(read|open|search|list|skim|inspect|explore|scan|run|execute|edit|fetch|download|creat(?:e|ing))\b"
290
+ )
291
+
292
+ _TOOL_CALL_MARKERS = ("<function_call>", "<tool_call>", "<|tool_call|>", "```tool_code")
293
+
294
+
295
+ def _contains_tool_call_markup(text: str) -> bool:
296
+ s = str(text or "")
297
+ if not s.strip():
298
+ return False
299
+ low = s.lower()
300
+ return any(m in low for m in _TOOL_CALL_MARKERS)
301
+
302
+
303
+ _TOOL_CALL_STRIP_RE = re.compile(
304
+ r"(?is)"
305
+ r"<function_call>\s*.*?\s*</function_call>|"
306
+ r"<tool_call>\s*.*?\s*</tool_call>|"
307
+ r"<\|tool_call\|>.*?<\|/tool_call\|>|"
308
+ r"```tool_code\s*.*?```"
309
+ )
310
+
311
+
312
+ def _strip_tool_call_markup(text: str) -> str:
313
+ raw = str(text or "")
314
+ if not raw.strip():
315
+ return ""
316
+ try:
317
+ return _TOOL_CALL_STRIP_RE.sub("", raw)
318
+ except Exception:
319
+ return raw
320
+
321
+
322
+ def _looks_like_deferred_action(text: str) -> bool:
323
+ """Return True when the model claims it will take actions but emits no tool calls.
324
+
325
+ This is intentionally conservative: false positives waste iterations and can "force"
326
+ unnecessary tool calls. It should only trigger when the assistant message strongly
327
+ suggests it is about to act (not answer).
328
+ """
329
+ s = str(text or "").strip()
330
+ if not s:
331
+ return False
332
+ # If the model is explicitly waiting for user direction, that's a valid final response.
333
+ if _WAITING_RE.search(s):
334
+ return False
335
+ # Common “final answer” framing (incl. typographic apostrophes).
336
+ if _FINALISH_RE.search(s):
337
+ return False
338
+ # If the model already produced a structured answer (headings/sections), don't retry.
339
+ if re.search(r"(?m)^(#{1,6}\s+\\S|\\*\\*\\S)", s):
340
+ return False
341
+ # Must contain first-person intent *and* an action-ish verb.
342
+ if not _DEFERRED_ACTION_INTENT_RE.search(s):
343
+ return False
344
+ if not _DEFERRED_ACTION_VERB_RE.search(s):
345
+ return False
346
+ return True
347
+
348
+
349
+ def _push_inbox(runtime_ns: Dict[str, Any], content: str) -> None:
350
+ if not isinstance(runtime_ns, dict):
351
+ return
352
+ inbox = runtime_ns.get("inbox")
353
+ if not isinstance(inbox, list):
354
+ inbox = []
355
+ runtime_ns["inbox"] = inbox
356
+ inbox.append({"role": "system", "content": str(content or "")})
357
+
358
+
359
+ def _drain_inbox(runtime_ns: Dict[str, Any]) -> str:
360
+ inbox = runtime_ns.get("inbox")
361
+ if not isinstance(inbox, list) or not inbox:
362
+ return ""
363
+ parts: list[str] = []
364
+ for m in inbox:
365
+ if not isinstance(m, dict):
366
+ continue
367
+ c = m.get("content")
368
+ if isinstance(c, str) and c.strip():
369
+ parts.append(c.strip())
370
+ runtime_ns["inbox"] = []
371
+ return "\n".join(parts).strip()
372
+
373
+
374
+ def _boolish(value: Any) -> bool:
375
+ """Best-effort coercion for runtime flags (bool/int/str)."""
376
+ if isinstance(value, bool):
377
+ return value
378
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
379
+ return value != 0
380
+ if isinstance(value, str):
381
+ return value.strip().lower() in {"1", "true", "yes", "y", "on", "enabled"}
382
+ return False
383
+
384
+ def _system_prompt_override(runtime_ns: Dict[str, Any]) -> Optional[str]:
385
+ raw = runtime_ns.get("system_prompt") if isinstance(runtime_ns, dict) else None
386
+ if isinstance(raw, str) and raw.strip():
387
+ return raw.strip()
388
+ return None
389
+
390
+
391
+ def _system_prompt_extra(runtime_ns: Dict[str, Any]) -> Optional[str]:
392
+ raw = runtime_ns.get("system_prompt_extra") if isinstance(runtime_ns, dict) else None
393
+ if isinstance(raw, str) and raw.strip():
394
+ return raw.strip()
395
+ return None
396
+
397
+
398
+ def _compose_system_prompt(runtime_ns: Dict[str, Any], *, base: str) -> str:
399
+ override = _system_prompt_override(runtime_ns)
400
+ extra = _system_prompt_extra(runtime_ns)
401
+ sys = override if override is not None else base
402
+ if extra:
403
+ sys = f"{sys.rstrip()}\n\nAdditional system instructions:\n{extra}"
404
+ return sys.strip()
405
+
406
+
407
+ def _max_output_tokens(runtime_ns: Dict[str, Any], limits: Dict[str, Any]) -> Optional[int]:
408
+ # Canonical limit: _limits.max_output_tokens (None = unset).
409
+ raw = None
410
+ if isinstance(limits, dict) and "max_output_tokens" in limits:
411
+ raw = limits.get("max_output_tokens")
412
+ if raw is None and isinstance(runtime_ns, dict):
413
+ raw = runtime_ns.get("max_output_tokens")
414
+ if raw is None:
415
+ return None
416
+ try:
417
+ val = int(raw)
418
+ except Exception:
419
+ return None
420
+ return val if val > 0 else None
421
+
422
+
423
+ def _render_cycles_for_system_prompt(scratchpad: Dict[str, Any]) -> str:
424
+ cycles = scratchpad.get("cycles")
425
+ if not isinstance(cycles, list) or not cycles:
426
+ return ""
427
+
428
+ # Keep the system prompt bounded: tool outputs can be very large (fetch_url/web_search).
429
+ max_cycles = 6
430
+ max_thought_chars = 600
431
+ max_obs_chars = 220
432
+
433
+ view = [c for c in cycles if isinstance(c, dict)]
434
+ if len(view) > max_cycles:
435
+ view = view[-max_cycles:]
436
+
437
+ lines: list[str] = []
438
+ for c in view:
439
+ i = c.get("i")
440
+ thought = str(c.get("thought") or "").strip()
441
+ if len(thought) > max_thought_chars:
442
+ thought = f"{thought[: max(0, max_thought_chars - 1)]}…"
443
+ tcs = c.get("tool_calls")
444
+ obs = c.get("observations")
445
+ if i is None:
446
+ continue
447
+ lines.append(f"[cycle {i}]")
448
+ if thought:
449
+ lines.append(f"thought: {thought}")
450
+ if isinstance(tcs, list) and tcs:
451
+ sigs: list[str] = []
452
+ for tc in tcs:
453
+ if isinstance(tc, dict):
454
+ sigs.append(_tool_call_signature(tc.get("name", ""), tc.get("arguments")))
455
+ if sigs:
456
+ lines.append("actions:")
457
+ for s in sigs:
458
+ lines.append(f"- {s}")
459
+ if isinstance(obs, list) and obs:
460
+ lines.append("observations:")
461
+ for o in obs:
462
+ if not isinstance(o, dict):
463
+ continue
464
+ name = str(o.get("name") or "tool")
465
+ ok = bool(o.get("success"))
466
+ out = o.get("output")
467
+ err = o.get("error")
468
+ if not ok:
469
+ text = str(err or out or "").strip()
470
+ else:
471
+ if isinstance(out, dict):
472
+ # Prefer metadata-ish fields; do not dump full `rendered` bodies into the prompt.
473
+ url = out.get("url") if isinstance(out.get("url"), str) else None
474
+ status = out.get("status_code") if out.get("status_code") is not None else None
475
+ content_type = out.get("content_type") if isinstance(out.get("content_type"), str) else None
476
+ rendered = out.get("rendered") if isinstance(out.get("rendered"), str) else None
477
+ rendered_len = len(rendered) if isinstance(rendered, str) else None
478
+ parts: list[str] = []
479
+ if url:
480
+ parts.append(f"url={url}")
481
+ if status is not None:
482
+ parts.append(f"status={status}")
483
+ if content_type:
484
+ parts.append(f"type={content_type}")
485
+ if rendered_len is not None:
486
+ parts.append(f"rendered_len={rendered_len}")
487
+ text = ", ".join(parts) if parts else f"keys={list(out.keys())[:8]}"
488
+ else:
489
+ text = str(out or "").strip()
490
+ if len(text) > max_obs_chars:
491
+ text = f"{text[: max(0, max_obs_chars - 1)]}…"
492
+ lines.append(f"- [{name}] {'OK' if ok else 'ERR'}: {text}")
493
+ lines.append("")
494
+ return "\n".join(lines).strip()
495
+
496
+
497
+ def _render_cycles_for_conclusion_prompt(scratchpad: Dict[str, Any]) -> str:
498
+ cycles = scratchpad.get("cycles")
499
+ if not isinstance(cycles, list) or not cycles:
500
+ return ""
501
+
502
+ # The conclusion prompt should have access to the full loop trace, but still needs
503
+ # to be bounded (tool outputs may be huge).
504
+ max_cycles = 25
505
+ max_thought_chars = 900
506
+ max_obs_chars = 360
507
+
508
+ view = [c for c in cycles if isinstance(c, dict)]
509
+ total = len(view)
510
+ if total > max_cycles:
511
+ view = view[-max_cycles:]
512
+
513
+ lines: list[str] = []
514
+ if total > len(view):
515
+ lines.append(f"(showing last {len(view)} of {total} cycles)")
516
+ lines.append("")
517
+
518
+ for c in view:
519
+ i = c.get("i")
520
+ if i is None:
521
+ continue
522
+ lines.append(f"[cycle {i}]")
523
+
524
+ thought = str(c.get("thought") or "").strip()
525
+ if len(thought) > max_thought_chars:
526
+ thought = f"{thought[: max(0, max_thought_chars - 1)]}…"
527
+ if thought:
528
+ lines.append(f"thought: {thought}")
529
+
530
+ tcs = c.get("tool_calls")
531
+ if isinstance(tcs, list) and tcs:
532
+ sigs: list[str] = []
533
+ for tc in tcs:
534
+ if isinstance(tc, dict):
535
+ sigs.append(_tool_call_signature(tc.get("name", ""), tc.get("arguments")))
536
+ if sigs:
537
+ lines.append("actions:")
538
+ for s in sigs:
539
+ lines.append(f"- {s}")
540
+
541
+ obs = c.get("observations")
542
+ if isinstance(obs, list) and obs:
543
+ lines.append("observations:")
544
+ for o in obs:
545
+ if not isinstance(o, dict):
546
+ continue
547
+ name = str(o.get("name") or "tool")
548
+ ok = bool(o.get("success"))
549
+ out = o.get("output")
550
+ err = o.get("error")
551
+ if not ok:
552
+ text = str(err or out or "").strip()
553
+ else:
554
+ if isinstance(out, dict):
555
+ url = out.get("url") if isinstance(out.get("url"), str) else None
556
+ status = out.get("status_code") if out.get("status_code") is not None else None
557
+ content_type = out.get("content_type") if isinstance(out.get("content_type"), str) else None
558
+ rendered = out.get("rendered") if isinstance(out.get("rendered"), str) else None
559
+ rendered_len = len(rendered) if isinstance(rendered, str) else None
560
+ parts: list[str] = []
561
+ if url:
562
+ parts.append(f"url={url}")
563
+ if status is not None:
564
+ parts.append(f"status={status}")
565
+ if content_type:
566
+ parts.append(f"type={content_type}")
567
+ if rendered_len is not None:
568
+ parts.append(f"rendered_len={rendered_len}")
569
+ text = ", ".join(parts) if parts else f"keys={list(out.keys())[:8]}"
570
+ else:
571
+ text = str(out or "").strip()
572
+ if len(text) > max_obs_chars:
573
+ text = f"{text[: max(0, max_obs_chars - 1)]}…"
574
+ lines.append(f"- [{name}] {'OK' if ok else 'ERR'}: {text}")
575
+
576
+ lines.append("")
577
+
578
+ return "\n".join(lines).strip()
579
+
580
+
581
+ def _render_final_report(task: str, scratchpad: Dict[str, Any]) -> str:
582
+ cycles = scratchpad.get("cycles")
583
+ if not isinstance(cycles, list):
584
+ cycles = []
585
+ lines: list[str] = []
586
+ lines.append(f"task: {task}")
587
+ lines.append(f"cycles: {len([c for c in cycles if isinstance(c, dict)])}")
588
+ lines.append("")
589
+ for c in cycles:
590
+ if not isinstance(c, dict):
591
+ continue
592
+ i = c.get("i")
593
+ lines.append(f"cycle {i}")
594
+ thought = str(c.get("thought") or "").strip()
595
+ if thought:
596
+ lines.append(f"- thought: {thought}")
597
+ tcs = c.get("tool_calls")
598
+ if isinstance(tcs, list) and tcs:
599
+ lines.append("- actions:")
600
+ for tc in tcs:
601
+ if not isinstance(tc, dict):
602
+ continue
603
+ lines.append(f" - {_tool_call_signature(tc.get('name',''), tc.get('arguments'))}")
604
+ obs = c.get("observations")
605
+ if isinstance(obs, list) and obs:
606
+ lines.append("- observations:")
607
+ for o in obs:
608
+ if not isinstance(o, dict):
609
+ continue
610
+ name = str(o.get("name") or "tool")
611
+ ok = bool(o.get("success"))
612
+ out = o.get("output")
613
+ err = o.get("error")
614
+ text = str(out if ok else (err or out) or "").strip()
615
+ lines.append(f" - [{name}] {'OK' if ok else 'ERR'}: {text}")
616
+ lines.append("")
617
+ return "\n".join(lines).strip()
618
+
619
+
102
620
  def create_react_workflow(
103
621
  *,
104
622
  logic: ReActLogic,
105
623
  on_step: Optional[Callable[[str, Dict[str, Any]], None]] = None,
624
+ workflow_id: str = "react_agent",
625
+ provider: Optional[str] = None,
626
+ model: Optional[str] = None,
627
+ allowed_tools: Optional[List[str]] = None,
106
628
  ) -> WorkflowSpec:
107
629
  """Adapt ReActLogic to an AbstractRuntime workflow."""
108
630
 
@@ -110,177 +632,708 @@ def create_react_workflow(
110
632
  if on_step:
111
633
  on_step(step, data)
112
634
 
113
- tool_defs = logic.tools
114
- tool_specs = [t.to_dict() for t in tool_defs]
115
- toolset_id = _compute_toolset_id(tool_specs)
635
+ def _current_tool_defs() -> list[Any]:
636
+ defs = getattr(logic, "tools", None)
637
+ if not isinstance(defs, list):
638
+ try:
639
+ defs = list(defs) # type: ignore[arg-type]
640
+ except Exception:
641
+ defs = []
642
+ return [t for t in defs if getattr(t, "name", None)]
643
+
644
+ def _tool_by_name() -> dict[str, Any]:
645
+ out: dict[str, Any] = {}
646
+ for t in _current_tool_defs():
647
+ name = getattr(t, "name", None)
648
+ if isinstance(name, str) and name.strip():
649
+ out[name] = t
650
+ return out
651
+
652
+ def _default_allowlist() -> list[str]:
653
+ if isinstance(allowed_tools, list):
654
+ allow = [str(t).strip() for t in allowed_tools if isinstance(t, str) and t.strip()]
655
+ return allow if allow else []
656
+ out: list[str] = []
657
+ seen: set[str] = set()
658
+ for t in _current_tool_defs():
659
+ name = getattr(t, "name", None)
660
+ if not isinstance(name, str) or not name.strip() or name in seen:
661
+ continue
662
+ seen.add(name)
663
+ out.append(name)
664
+ return out
665
+
666
+ def _normalize_allowlist(raw: Any) -> list[str]:
667
+ if isinstance(raw, list):
668
+ items = raw
669
+ elif isinstance(raw, tuple):
670
+ items = list(raw)
671
+ elif isinstance(raw, str):
672
+ items = [raw]
673
+ else:
674
+ items = []
675
+
676
+ current = _tool_by_name()
677
+ out: list[str] = []
678
+ seen: set[str] = set()
679
+ for t in items:
680
+ if not isinstance(t, str):
681
+ continue
682
+ name = t.strip()
683
+ if not name or name in seen or name not in current:
684
+ continue
685
+ seen.add(name)
686
+ out.append(name)
687
+ return out
688
+
689
+ def _effective_allowlist(runtime_ns: Dict[str, Any]) -> list[str]:
690
+ if isinstance(runtime_ns, dict) and "allowed_tools" in runtime_ns:
691
+ normalized = _normalize_allowlist(runtime_ns.get("allowed_tools"))
692
+ runtime_ns["allowed_tools"] = normalized
693
+ return normalized
694
+ return _normalize_allowlist(list(_default_allowlist()))
695
+
696
+ def _allowed_tool_defs(allow: list[str]) -> list[Any]:
697
+ out: list[Any] = []
698
+ current = _tool_by_name()
699
+ for name in allow:
700
+ tool = current.get(name)
701
+ if tool is not None:
702
+ out.append(tool)
703
+ return out
704
+
705
+ def _tool_prompt_examples_enabled(runtime_ns: Dict[str, Any]) -> bool:
706
+ raw = runtime_ns.get("tool_prompt_examples") if isinstance(runtime_ns, dict) else None
707
+ if raw is None:
708
+ return True
709
+ if isinstance(raw, bool):
710
+ return raw
711
+ if isinstance(raw, (int, float)):
712
+ return bool(raw)
713
+ if isinstance(raw, str):
714
+ lowered = raw.strip().lower()
715
+ if lowered in {"0", "false", "no", "off", "disabled"}:
716
+ return False
717
+ if lowered in {"1", "true", "yes", "on", "enabled"}:
718
+ return True
719
+ return True
720
+
721
+ def _materialize_tool_specs(defs: list[Any], *, include_examples: bool) -> list[dict[str, Any]]:
722
+ out: list[dict[str, Any]] = []
723
+ for t in defs:
724
+ try:
725
+ d = t.to_dict()
726
+ except Exception:
727
+ continue
728
+ if isinstance(d, dict):
729
+ if not include_examples:
730
+ d = dict(d)
731
+ d.pop("examples", None)
732
+ out.append(d)
733
+ return out
734
+
735
+ def _sanitize_llm_messages(messages: Any) -> List[Dict[str, Any]]:
736
+ if not isinstance(messages, list) or not messages:
737
+ return []
738
+ out: List[Dict[str, Any]] = []
739
+
740
+ def _sanitize_tool_calls(raw: Any) -> Optional[list[dict[str, Any]]]:
741
+ if not isinstance(raw, list) or not raw:
742
+ return None
743
+ cleaned: list[dict[str, Any]] = []
744
+ for i, tc in enumerate(raw):
745
+ if not isinstance(tc, dict):
746
+ continue
747
+ tc_type = str(tc.get("type") or "function")
748
+ if tc_type != "function":
749
+ continue
750
+ call_id = tc.get("id")
751
+ call_id_str = str(call_id).strip() if call_id is not None else ""
752
+ if not call_id_str:
753
+ call_id_str = f"call_{i+1}"
754
+ fn = tc.get("function") if isinstance(tc.get("function"), dict) else {}
755
+ name = str(fn.get("name") or "").strip()
756
+ if not name:
757
+ continue
758
+ args = fn.get("arguments")
759
+ if isinstance(args, dict):
760
+ args_str = json.dumps(args, ensure_ascii=False)
761
+ else:
762
+ args_str = "" if args is None else str(args)
763
+ cleaned.append({"type": "function", "id": call_id_str, "function": {"name": name, "arguments": args_str}})
764
+ return cleaned or None
765
+
766
+ for m in messages:
767
+ if not isinstance(m, dict):
768
+ continue
769
+ role = str(m.get("role") or "").strip()
770
+ if not role:
771
+ continue
772
+ content = m.get("content")
773
+ content_str = "" if content is None else str(content)
774
+ tool_calls_raw = m.get("tool_calls")
775
+ tool_calls = _sanitize_tool_calls(tool_calls_raw)
776
+
777
+ # Assistant tool-calls messages may legitimately have empty content, but must still be included.
778
+ if not content_str.strip() and not (role == "assistant" and tool_calls):
779
+ continue
780
+
781
+ entry: Dict[str, Any] = {"role": role, "content": content_str}
782
+ if role == "tool":
783
+ meta = m.get("metadata") if isinstance(m.get("metadata"), dict) else {}
784
+ call_id = meta.get("call_id") if isinstance(meta, dict) else None
785
+ if call_id is not None and str(call_id).strip():
786
+ entry["tool_call_id"] = str(call_id).strip()
787
+ elif role == "assistant" and tool_calls:
788
+ entry["tool_calls"] = tool_calls
789
+ out.append(entry)
790
+ return out
791
+
792
+ builtin_effect_tools = {
793
+ "ask_user",
794
+ "recall_memory",
795
+ "inspect_vars",
796
+ "remember",
797
+ "remember_note",
798
+ "compact_memory",
799
+ "delegate_agent",
800
+ }
116
801
 
117
802
  def init_node(run: RunState, ctx) -> StepPlan:
118
803
  context, scratchpad, runtime_ns, _, limits = ensure_react_vars(run)
804
+
119
805
  scratchpad["iteration"] = 0
120
806
  limits["current_iteration"] = 0
121
807
 
808
+ # Disable runtime-level input trimming for ReAct loops.
809
+ if isinstance(runtime_ns, dict):
810
+ runtime_ns.setdefault("disable_input_trimming", True)
811
+ # Disable all truncation/capping knobs for ReAct runs (policy: full context for now).
812
+ # These can be re-enabled later once correctness is proven.
813
+ if isinstance(limits, dict):
814
+ limits["max_output_tokens"] = None
815
+ limits["max_input_tokens"] = None
816
+ limits["max_history_messages"] = -1
817
+ limits["max_message_chars"] = -1
818
+ limits["max_tool_message_chars"] = -1
819
+
122
820
  task = str(context.get("task", "") or "")
123
821
  context["task"] = task
124
- messages = context["messages"]
125
-
126
- if task and (not messages or messages[-1].get("role") != "user" or messages[-1].get("content") != task):
127
- messages.append(_new_message(ctx, role="user", content=task))
128
-
129
- # Ensure toolset metadata is present for audit/debug.
130
- runtime_ns.setdefault("tool_specs", tool_specs)
131
- runtime_ns.setdefault("toolset_id", toolset_id)
132
- runtime_ns.setdefault("inbox", [])
133
-
134
- emit("init", {"task": task})
822
+ msgs = context.get("messages")
823
+ if not isinstance(msgs, list):
824
+ msgs = []
825
+ context["messages"] = msgs
826
+
827
+ if task and (not msgs or msgs[-1].get("role") != "user" or msgs[-1].get("content") != task):
828
+ msgs.append(_new_message(ctx, role="user", content=task))
829
+
830
+ allow = _effective_allowlist(runtime_ns)
831
+ allowed_defs = _allowed_tool_defs(allow)
832
+ include_examples = _tool_prompt_examples_enabled(runtime_ns)
833
+ tool_specs = _materialize_tool_specs(allowed_defs, include_examples=include_examples)
834
+ runtime_ns["tool_specs"] = tool_specs
835
+ runtime_ns["toolset_id"] = _compute_toolset_id(tool_specs)
836
+ runtime_ns.setdefault("allowed_tools", allow)
837
+
838
+ scratchpad.setdefault("cycles", [])
135
839
  return StepPlan(node_id="init", next_node="reason")
136
840
 
137
841
  def reason_node(run: RunState, ctx) -> StepPlan:
138
- context, scratchpad, runtime_ns, _, limits = ensure_react_vars(run)
842
+ context, scratchpad, runtime_ns, temp, limits = ensure_react_vars(run)
139
843
 
140
- # Read from _limits (canonical) with fallback to scratchpad (backward compat)
141
- if "current_iteration" in limits:
142
- iteration = int(limits.get("current_iteration", 0) or 0)
143
- max_iterations = int(limits.get("max_iterations", 25) or 25)
144
- else:
145
- # Backward compatibility: use scratchpad
146
- iteration = int(scratchpad.get("iteration", 0) or 0)
147
- max_iterations = int(scratchpad.get("max_iterations") or 25)
844
+ # Durable resume safety:
845
+ # - tool definitions can change across restarts (env/toolset swaps, staged deploy swaps)
846
+ # - allowlists can be edited at runtime by hosts
847
+ # `tool_specs` must match the effective allowlist + current tool defs, otherwise the LLM may
848
+ # see tools it cannot execute ("tool not allowed") or see stale schemas (signature mismatch).
849
+ try:
850
+ if isinstance(runtime_ns, dict):
851
+ allow = _effective_allowlist(runtime_ns)
852
+ allowed_defs = _allowed_tool_defs(allow)
853
+ include_examples = _tool_prompt_examples_enabled(runtime_ns)
854
+ refreshed_specs = _materialize_tool_specs(allowed_defs, include_examples=include_examples)
855
+ refreshed_id = _compute_toolset_id(refreshed_specs)
856
+ prev_id = str(runtime_ns.get("toolset_id") or "")
857
+ prev_specs = runtime_ns.get("tool_specs")
858
+ if refreshed_id != prev_id or not isinstance(prev_specs, list):
859
+ runtime_ns["tool_specs"] = refreshed_specs
860
+ runtime_ns["toolset_id"] = refreshed_id
861
+ runtime_ns.setdefault("allowed_tools", allow)
862
+ except Exception:
863
+ pass
148
864
 
865
+ max_iterations = int(limits.get("max_iterations", 0) or scratchpad.get("max_iterations", 25) or 25)
149
866
  if max_iterations < 1:
150
867
  max_iterations = 1
151
868
 
152
- if iteration >= max_iterations:
869
+ iteration = int(scratchpad.get("iteration", 0) or 0) + 1
870
+ if iteration > max_iterations:
153
871
  return StepPlan(node_id="reason", next_node="max_iterations")
154
872
 
155
- # Update both for transition period
156
- scratchpad["iteration"] = iteration + 1
157
- limits["current_iteration"] = iteration + 1
873
+ scratchpad["iteration"] = iteration
874
+ limits["current_iteration"] = iteration
158
875
 
159
876
  task = str(context.get("task", "") or "")
160
- messages = context["messages"]
161
-
162
- inbox = runtime_ns.get("inbox", [])
163
- guidance = ""
164
- if isinstance(inbox, list) and inbox:
165
- inbox_messages = [str(m.get("content", "") or "") for m in inbox if isinstance(m, dict)]
166
- guidance = " | ".join([m for m in inbox_messages if m])
167
- runtime_ns["inbox"] = []
877
+ messages_view = list(context.get("messages") or [])
168
878
 
879
+ guidance = _drain_inbox(runtime_ns)
169
880
  req = logic.build_request(
170
881
  task=task,
171
- messages=messages,
882
+ messages=messages_view,
172
883
  guidance=guidance,
173
- iteration=iteration + 1,
884
+ iteration=iteration,
174
885
  max_iterations=max_iterations,
175
- vars=run.vars, # Pass vars for _limits access
886
+ vars=run.vars,
176
887
  )
177
888
 
178
- emit("reason", {"iteration": iteration + 1, "max_iterations": max_iterations, "has_guidance": bool(guidance)})
889
+ emit("reason", {"iteration": iteration, "max_iterations": max_iterations, "has_guidance": bool(guidance)})
179
890
 
180
- payload = {"prompt": req.prompt, "tools": [t.to_dict() for t in req.tools]}
181
- if req.max_tokens is not None:
182
- payload["params"] = {"max_tokens": req.max_tokens}
891
+ payload: Dict[str, Any] = {"prompt": ""}
892
+ sanitized_messages = _sanitize_llm_messages(messages_view)
893
+ if sanitized_messages:
894
+ payload["messages"] = sanitized_messages
895
+ else:
896
+ # Ensure LLM_CALL contract is satisfied even for one-shot runs where callers
897
+ # provide only `context.task` and no `context.messages`.
898
+ task_text = str(task or "").strip()
899
+ if task_text:
900
+ payload["prompt"] = task_text
901
+ media = extract_media_from_context(context)
902
+ if media:
903
+ payload["media"] = media
904
+
905
+ tool_specs = runtime_ns.get("tool_specs") if isinstance(runtime_ns, dict) else None
906
+ if isinstance(tool_specs, list) and tool_specs:
907
+ payload["tools"] = list(tool_specs)
908
+
909
+ sys_base = str(req.system_prompt or "").strip()
910
+ sys = _compose_system_prompt(runtime_ns, base=sys_base)
911
+ # Append scratchpad only when not using a full override prompt.
912
+ if _system_prompt_override(runtime_ns) is None:
913
+ scratch_txt = _render_cycles_for_system_prompt(scratchpad)
914
+ if scratch_txt:
915
+ sys = f"{sys.rstrip()}\n\n## Scratchpad (ReAct cycles so far)\n{scratch_txt}".strip()
916
+ if sys:
917
+ payload["system_prompt"] = sys
918
+
919
+ eff_provider = provider if isinstance(provider, str) and provider.strip() else runtime_ns.get("provider")
920
+ eff_model = model if isinstance(model, str) and model.strip() else runtime_ns.get("model")
921
+ if isinstance(eff_provider, str) and eff_provider.strip():
922
+ payload["provider"] = eff_provider.strip()
923
+ if isinstance(eff_model, str) and eff_model.strip():
924
+ payload["model"] = eff_model.strip()
925
+
926
+ params: Dict[str, Any] = {}
927
+ max_out = _max_output_tokens(runtime_ns, limits)
928
+ if isinstance(max_out, int) and max_out > 0:
929
+ params["max_tokens"] = max_out
930
+ # Tool calling is formatting-sensitive; bias toward a lower temperature when tools are present,
931
+ # unless the caller explicitly sets `_runtime.temperature`.
932
+ default_temp = 0.2 if isinstance(tool_specs, list) and tool_specs else 0.7
933
+ payload["params"] = runtime_llm_params(runtime_ns, extra=params, default_temperature=default_temp)
183
934
 
184
935
  return StepPlan(
185
936
  node_id="reason",
186
- effect=Effect(
187
- type=EffectType.LLM_CALL,
188
- payload=payload,
189
- result_key="_temp.llm_response",
190
- ),
937
+ effect=Effect(type=EffectType.LLM_CALL, payload=payload, result_key="_temp.llm_response"),
191
938
  next_node="parse",
192
939
  )
193
940
 
194
941
  def parse_node(run: RunState, ctx) -> StepPlan:
195
- context, _, _, temp, _ = ensure_react_vars(run)
942
+ context, scratchpad, runtime_ns, temp, limits = ensure_react_vars(run)
196
943
  response = temp.get("llm_response", {})
197
- content, tool_calls = logic.parse_response(response)
198
944
 
199
- context["messages"].append(_new_message(ctx, role="assistant", content=content))
945
+ content, tool_calls = logic.parse_response(response)
946
+ finish_reason = ""
947
+ if isinstance(response, dict):
948
+ fr = response.get("finish_reason")
949
+ finish_reason = str(fr or "").strip().lower() if fr is not None else ""
200
950
 
951
+ cycle_i = int(scratchpad.get("iteration", 0) or 0)
952
+ max_iterations = int(limits.get("max_iterations", 0) or scratchpad.get("max_iterations", 25) or 25)
953
+ if max_iterations < 1:
954
+ max_iterations = 1
955
+ reasoning_text = ""
956
+ try:
957
+ if isinstance(response, dict):
958
+ rc = response.get("reasoning")
959
+ if rc is None:
960
+ rc = response.get("reasoning_content")
961
+ reasoning_text = str(rc or "")
962
+ except Exception:
963
+ reasoning_text = ""
201
964
  emit(
202
965
  "parse",
203
966
  {
967
+ "iteration": cycle_i,
968
+ "max_iterations": max_iterations,
204
969
  "has_tool_calls": bool(tool_calls),
205
- "content_preview": content[:100] if content else "(no content)",
970
+ "content": str(content or ""),
971
+ "reasoning": reasoning_text,
206
972
  },
207
973
  )
208
- temp.pop("llm_response", None)
974
+ cycle: Dict[str, Any] = {"i": cycle_i, "thought": content, "tool_calls": [], "observations": []}
975
+ cycles = scratchpad.get("cycles")
976
+ if isinstance(cycles, list):
977
+ cycles.append(cycle)
978
+ else:
979
+ scratchpad["cycles"] = [cycle]
209
980
 
210
981
  if tool_calls:
982
+ cycle["tool_calls"] = [tc.__dict__ for tc in tool_calls]
983
+
984
+ # Loop guard: some models may repeat the exact same tool calls (including side effects)
985
+ # even after receiving successful observations. Skip executing duplicates to avoid
986
+ # repeatedly overwriting files or re-running commands.
987
+ try:
988
+ side_effect_tools = {
989
+ "write_file",
990
+ "edit_file",
991
+ "execute_command",
992
+ # Comms tools (side-effectful; avoid duplicate sends).
993
+ "send_email",
994
+ "send_whatsapp_message",
995
+ "send_telegram_message",
996
+ "send_telegram_artifact",
997
+ }
998
+ has_side_effect = any(
999
+ isinstance(getattr(tc, "name", None), str) and str(getattr(tc, "name") or "").strip() in side_effect_tools
1000
+ for tc in tool_calls
1001
+ )
1002
+
1003
+ if has_side_effect:
1004
+ cycles_list = scratchpad.get("cycles")
1005
+ prev_cycle: Optional[Dict[str, Any]] = None
1006
+ if isinstance(cycles_list, list) and len(cycles_list) >= 2:
1007
+ for c in reversed(cycles_list[:-1]):
1008
+ if not isinstance(c, dict):
1009
+ continue
1010
+ prev_tcs = c.get("tool_calls")
1011
+ if isinstance(prev_tcs, list) and prev_tcs:
1012
+ prev_cycle = c
1013
+ break
1014
+
1015
+ def _cycle_fps(c: Dict[str, Any]) -> list[str]:
1016
+ tcs2 = c.get("tool_calls")
1017
+ if not isinstance(tcs2, list) or not tcs2:
1018
+ return []
1019
+ fps: list[str] = []
1020
+ for tc in tcs2:
1021
+ if not isinstance(tc, dict):
1022
+ continue
1023
+ fps.append(_tool_call_fingerprint(tc.get("name", ""), tc.get("arguments")))
1024
+ return fps
1025
+
1026
+ def _cycle_obs_all_ok(c: Dict[str, Any]) -> bool:
1027
+ obs2 = c.get("observations")
1028
+ if not isinstance(obs2, list) or not obs2:
1029
+ return False
1030
+ for o in obs2:
1031
+ if not isinstance(o, dict):
1032
+ return False
1033
+ if o.get("success") is not True:
1034
+ return False
1035
+ return True
1036
+
1037
+ if prev_cycle is not None and _cycle_obs_all_ok(prev_cycle):
1038
+ prev_fps = _cycle_fps(prev_cycle)
1039
+ cur_fps = [_tool_call_fingerprint(tc.name, tc.arguments) for tc in tool_calls]
1040
+ if prev_fps and prev_fps == cur_fps:
1041
+ _push_inbox(
1042
+ runtime_ns,
1043
+ "You are repeating the exact same tool calls as the previous cycle, and they already succeeded.\n"
1044
+ "Do NOT execute them again (to avoid duplicate side effects).\n"
1045
+ "Instead, use the existing tool outputs and provide the final answer with NO tool calls.",
1046
+ )
1047
+ emit("parse_repeat_tool_calls", {"cycle": cycle_i, "count": len(tool_calls)})
1048
+ temp["pending_tool_calls"] = []
1049
+ return StepPlan(node_id="parse", next_node="reason")
1050
+ except Exception:
1051
+ pass
1052
+
1053
+ # Keep tool transcript in context for OpenAI-compatible tool calling.
1054
+ context["messages"].append(
1055
+ _new_assistant_message_with_tool_calls(
1056
+ ctx,
1057
+ content="", # thought is stored in scratchpad (not user-visible history)
1058
+ tool_calls=tool_calls,
1059
+ metadata={"kind": "tool_calls", "cycle": cycle_i},
1060
+ )
1061
+ )
211
1062
  temp["pending_tool_calls"] = [tc.__dict__ for tc in tool_calls]
1063
+ emit("parse_tool_calls", {"count": len(tool_calls)})
212
1064
  return StepPlan(node_id="parse", next_node="act")
213
1065
 
214
- temp["final_answer"] = content
1066
+ # If the model hit an output limit, treat the step as incomplete and continue.
1067
+ if finish_reason in {"length", "max_tokens"}:
1068
+ _push_inbox(
1069
+ runtime_ns,
1070
+ "Your previous response hit an output token limit before producing a complete tool call.\n"
1071
+ "Retry now: emit ONLY the next tool call(s) needed to make progress.\n"
1072
+ "Keep tool call arguments small (avoid large file contents / giant JSON blobs) to prevent tool-call truncation.\n"
1073
+ "For large files, create a small skeleton first, then refine via multiple smaller edits/tool calls.\n"
1074
+ "Do not write a long plan before tool calls.",
1075
+ )
1076
+ emit("parse_retry_truncated", {"cycle": cycle_i})
1077
+ return StepPlan(node_id="parse", next_node="reason")
1078
+
1079
+ if not isinstance(content, str) or not content.strip():
1080
+ _push_inbox(runtime_ns, "Your previous response was empty. Continue the task.")
1081
+ emit("parse_retry_empty", {"cycle": cycle_i})
1082
+ return StepPlan(node_id="parse", next_node="reason")
1083
+
1084
+ # Followthrough heuristic: retry when the model claims it will take actions but emits no tool calls.
1085
+ # Default ON (disable with `_runtime.check_plan=false`).
1086
+ raw_check_plan = runtime_ns.get("check_plan") if isinstance(runtime_ns, dict) else None
1087
+ check_plan = True if raw_check_plan is None else _boolish(raw_check_plan)
1088
+ if check_plan and cycle_i < max_iterations and _looks_like_deferred_action(content):
1089
+ _push_inbox(
1090
+ runtime_ns,
1091
+ "You said you would take an action, but you did not call any tools.\n"
1092
+ "If you need to act, call the next tool now (emit ONLY the next tool call(s)).\n"
1093
+ "If you are already done, provide the final answer with NO tool calls.",
1094
+ )
1095
+ emit("parse_retry_plan_only", {"cycle": cycle_i})
1096
+ return StepPlan(node_id="parse", next_node="reason")
1097
+
1098
+ # Final answer: stop the loop.
1099
+ answer = str(content).strip()
1100
+ temp["final_answer"] = answer
1101
+ emit("parse_final", {"cycle": cycle_i})
215
1102
  return StepPlan(node_id="parse", next_node="done")
216
1103
 
217
1104
  def act_node(run: RunState, ctx) -> StepPlan:
218
- _, _, _, temp, _ = ensure_react_vars(run)
219
- tool_calls = temp.get("pending_tool_calls", [])
220
- if not isinstance(tool_calls, list):
221
- tool_calls = []
1105
+ context, scratchpad, runtime_ns, temp, limits = ensure_react_vars(run)
222
1106
 
223
- if not tool_calls:
224
- return StepPlan(node_id="act", next_node="reason")
1107
+ pending = temp.get("pending_tool_calls", [])
1108
+ if not isinstance(pending, list):
1109
+ pending = []
225
1110
 
226
- # Handle ask_user specially with ASK_USER effect.
227
- for i, tc in enumerate(tool_calls):
228
- if not isinstance(tc, dict):
229
- continue
230
- if tc.get("name") != "ask_user":
1111
+ cycle_i = int(scratchpad.get("iteration", 0) or 0)
1112
+ max_iterations = int(limits.get("max_iterations", 0) or scratchpad.get("max_iterations", 25) or 25)
1113
+ if max_iterations < 1:
1114
+ max_iterations = 1
1115
+
1116
+ tool_queue: list[Dict[str, Any]] = []
1117
+ for idx, tc in enumerate(pending):
1118
+ if isinstance(tc, ToolCall):
1119
+ d = tc.__dict__
1120
+ elif isinstance(tc, dict):
1121
+ d = dict(tc)
1122
+ else:
231
1123
  continue
1124
+ if "call_id" not in d or not d.get("call_id"):
1125
+ d["call_id"] = str(idx)
1126
+ tool_queue.append(d)
1127
+
1128
+ if not tool_queue:
1129
+ temp["pending_tool_calls"] = []
1130
+ return StepPlan(node_id="act", next_node="reason")
1131
+
1132
+ allow = _effective_allowlist(runtime_ns)
1133
+
1134
+ def _is_builtin(tc: Dict[str, Any]) -> bool:
1135
+ name = tc.get("name")
1136
+ return isinstance(name, str) and name in builtin_effect_tools
1137
+
1138
+ if _is_builtin(tool_queue[0]):
1139
+ tc = tool_queue[0]
1140
+ name = str(tc.get("name") or "").strip()
232
1141
  args = tc.get("arguments") or {}
233
- question = str(args.get("question") or "Please provide input:")
234
- choices = args.get("choices")
235
- choices = list(choices) if isinstance(choices, list) else None
1142
+ if not isinstance(args, dict):
1143
+ args = {}
1144
+
1145
+ temp["pending_tool_calls"] = list(tool_queue[1:])
1146
+
1147
+ if name and name not in allow:
1148
+ temp["tool_results"] = {
1149
+ "results": [
1150
+ {
1151
+ "call_id": str(tc.get("call_id") or ""),
1152
+ "name": name,
1153
+ "success": False,
1154
+ "output": None,
1155
+ "error": f"Tool '{name}' is not allowed for this agent",
1156
+ }
1157
+ ]
1158
+ }
1159
+ emit("act_blocked", {"tool": name})
1160
+ return StepPlan(node_id="act", next_node="observe")
1161
+
1162
+ if name == "ask_user":
1163
+ question = str(args.get("question") or "Please provide input:")
1164
+ choices = args.get("choices")
1165
+ choices = list(choices) if isinstance(choices, list) else None
1166
+
1167
+ msgs = context.get("messages")
1168
+ if isinstance(msgs, list):
1169
+ msgs.append(
1170
+ _new_message(ctx, role="assistant", content=f"[Agent question]: {question}", metadata={"kind": "ask_user_prompt"})
1171
+ )
1172
+
1173
+ emit("ask_user", {"question": question, "choices": choices or []})
1174
+ return StepPlan(
1175
+ node_id="act",
1176
+ effect=Effect(
1177
+ type=EffectType.ASK_USER,
1178
+ payload={"prompt": question, "choices": choices, "allow_free_text": True},
1179
+ result_key="_temp.user_response",
1180
+ ),
1181
+ next_node="handle_user_response",
1182
+ )
236
1183
 
237
- temp["pending_tool_calls"] = tool_calls[i + 1 :]
238
- emit("ask_user", {"question": question, "choices": choices or []})
239
- return StepPlan(
240
- node_id="act",
241
- effect=Effect(
242
- type=EffectType.ASK_USER,
243
- payload={"prompt": question, "choices": choices, "allow_free_text": True},
244
- result_key="_temp.user_response",
245
- ),
246
- next_node="handle_user_response",
247
- )
1184
+ if name == "recall_memory":
1185
+ payload = dict(args)
1186
+ payload.setdefault("tool_name", "recall_memory")
1187
+ payload.setdefault("call_id", tc.get("call_id") or "memory")
1188
+ emit("memory_query", {"query": payload.get("query"), "span_id": payload.get("span_id")})
1189
+ return StepPlan(
1190
+ node_id="act",
1191
+ effect=Effect(type=EffectType.MEMORY_QUERY, payload=payload, result_key="_temp.tool_results"),
1192
+ next_node="observe",
1193
+ )
248
1194
 
249
- for tc in tool_calls:
250
- if isinstance(tc, dict):
251
- emit("act", {"tool": tc.get("name", ""), "args": tc.get("arguments", {})})
1195
+ if name == "inspect_vars":
1196
+ payload = dict(args)
1197
+ payload.setdefault("tool_name", "inspect_vars")
1198
+ payload.setdefault("call_id", tc.get("call_id") or "vars")
1199
+ emit("vars_query", {"path": payload.get("path")})
1200
+ return StepPlan(
1201
+ node_id="act",
1202
+ effect=Effect(type=EffectType.VARS_QUERY, payload=payload, result_key="_temp.tool_results"),
1203
+ next_node="observe",
1204
+ )
252
1205
 
253
- formatted_calls: List[Dict[str, Any]] = []
254
- for tc in tool_calls:
255
- if isinstance(tc, dict):
256
- formatted_calls.append(
257
- {
258
- "name": tc.get("name", ""),
259
- "arguments": tc.get("arguments", {}),
260
- "call_id": tc.get("call_id", "1"),
261
- }
1206
+ if name == "remember":
1207
+ payload = dict(args)
1208
+ payload.setdefault("tool_name", "remember")
1209
+ payload.setdefault("call_id", tc.get("call_id") or "memory")
1210
+ emit("memory_tag", {"span_id": payload.get("span_id"), "tags": payload.get("tags")})
1211
+ return StepPlan(
1212
+ node_id="act",
1213
+ effect=Effect(type=EffectType.MEMORY_TAG, payload=payload, result_key="_temp.tool_results"),
1214
+ next_node="observe",
1215
+ )
1216
+
1217
+ if name == "remember_note":
1218
+ payload = dict(args)
1219
+ payload.setdefault("tool_name", "remember_note")
1220
+ payload.setdefault("call_id", tc.get("call_id") or "memory")
1221
+ emit("memory_note", {"note": payload.get("note"), "tags": payload.get("tags")})
1222
+ return StepPlan(
1223
+ node_id="act",
1224
+ effect=Effect(type=EffectType.MEMORY_NOTE, payload=payload, result_key="_temp.tool_results"),
1225
+ next_node="observe",
262
1226
  )
263
- elif isinstance(tc, ToolCall):
264
- formatted_calls.append(
265
- {
266
- "name": tc.name,
267
- "arguments": tc.arguments,
268
- "call_id": tc.call_id or "1",
1227
+
1228
+ if name == "compact_memory":
1229
+ payload = dict(args)
1230
+ payload.setdefault("tool_name", "compact_memory")
1231
+ payload.setdefault("call_id", tc.get("call_id") or "compact")
1232
+ emit("memory_compact", {"preserve_recent": payload.get("preserve_recent"), "mode": payload.get("compression_mode")})
1233
+ return StepPlan(
1234
+ node_id="act",
1235
+ effect=Effect(type=EffectType.MEMORY_COMPACT, payload=payload, result_key="_temp.tool_results"),
1236
+ next_node="observe",
1237
+ )
1238
+
1239
+ if name == "delegate_agent":
1240
+ delegated_task = str(args.get("task") or "").strip()
1241
+ delegated_context = str(args.get("context") or "").strip()
1242
+
1243
+ tools_raw = args.get("tools")
1244
+ if tools_raw is None:
1245
+ # Inherit the current allowlist, but avoid recursive delegation and avoid waiting on ask_user
1246
+ # unless explicitly enabled.
1247
+ child_allow = [t for t in allow if t not in {"delegate_agent", "ask_user"}]
1248
+ else:
1249
+ child_allow = _normalize_allowlist(tools_raw)
1250
+
1251
+ if not delegated_task:
1252
+ temp["tool_results"] = {
1253
+ "results": [
1254
+ {
1255
+ "call_id": str(tc.get("call_id") or ""),
1256
+ "name": "delegate_agent",
1257
+ "success": False,
1258
+ "output": None,
1259
+ "error": "delegate_agent requires a non-empty task",
1260
+ }
1261
+ ]
269
1262
  }
1263
+ return StepPlan(node_id="act", next_node="observe")
1264
+
1265
+ combined_task = delegated_task
1266
+ if delegated_context:
1267
+ combined_task = f"{delegated_task}\n\nContext:\n{delegated_context}"
1268
+
1269
+ sub_vars: Dict[str, Any] = {
1270
+ "context": {"task": combined_task, "messages": []},
1271
+ "_runtime": {
1272
+ "allowed_tools": list(child_allow),
1273
+ "system_prompt_extra": (
1274
+ "You are a delegated sub-agent.\n"
1275
+ "- Focus ONLY on the delegated task.\n"
1276
+ "- Use ONLY the allowed tools when needed.\n"
1277
+ "- Do not ask the user questions; if blocked, state assumptions and proceed.\n"
1278
+ "- Return a concise result suitable for the parent agent to act on.\n"
1279
+ ),
1280
+ },
1281
+ "_limits": {"max_iterations": 10},
1282
+ }
1283
+
1284
+ payload = {
1285
+ "workflow_id": str(getattr(run, "workflow_id", "") or "react_agent"),
1286
+ "vars": sub_vars,
1287
+ "async": False,
1288
+ "include_traces": False,
1289
+ # Tool-mode wrapper so the parent receives a normal tool observation (no run failure on child failure).
1290
+ "wrap_as_tool_result": True,
1291
+ "tool_name": "delegate_agent",
1292
+ "call_id": str(tc.get("call_id") or ""),
1293
+ }
1294
+ emit("delegate_agent", {"tools": list(child_allow), "call_id": payload.get("call_id")})
1295
+ return StepPlan(
1296
+ node_id="act",
1297
+ effect=Effect(type=EffectType.START_SUBWORKFLOW, payload=payload, result_key="_temp.tool_results"),
1298
+ next_node="observe",
270
1299
  )
271
1300
 
1301
+ # Unknown builtin: continue.
1302
+ return StepPlan(node_id="act", next_node="act" if temp.get("pending_tool_calls") else "reason")
1303
+
1304
+ batch: List[Dict[str, Any]] = []
1305
+ for tc in tool_queue:
1306
+ if _is_builtin(tc):
1307
+ break
1308
+ batch.append(tc)
1309
+
1310
+ remaining = tool_queue[len(batch) :]
1311
+ temp["pending_tool_calls"] = list(remaining)
1312
+
1313
+ formatted_calls: List[Dict[str, Any]] = []
1314
+ for tc in batch:
1315
+ emit(
1316
+ "act",
1317
+ {
1318
+ "iteration": cycle_i,
1319
+ "max_iterations": max_iterations,
1320
+ "tool": tc.get("name", ""),
1321
+ "args": tc.get("arguments", {}),
1322
+ "call_id": str(tc.get("call_id") or ""),
1323
+ },
1324
+ )
1325
+ formatted_calls.append(
1326
+ {"name": tc.get("name", ""), "arguments": tc.get("arguments", {}), "call_id": str(tc.get("call_id") or "")}
1327
+ )
1328
+
272
1329
  return StepPlan(
273
1330
  node_id="act",
274
- effect=Effect(
275
- type=EffectType.TOOL_CALLS,
276
- payload={"tool_calls": formatted_calls},
277
- result_key="_temp.tool_results",
278
- ),
1331
+ effect=Effect(type=EffectType.TOOL_CALLS, payload={"tool_calls": formatted_calls, "allowed_tools": list(allow)}, result_key="_temp.tool_results"),
279
1332
  next_node="observe",
280
1333
  )
281
1334
 
282
1335
  def observe_node(run: RunState, ctx) -> StepPlan:
283
- context, _, _, temp, _ = ensure_react_vars(run)
1336
+ context, scratchpad, _, temp, _ = ensure_react_vars(run)
284
1337
  tool_results = temp.get("tool_results", {})
285
1338
  if not isinstance(tool_results, dict):
286
1339
  tool_results = {}
@@ -289,6 +1342,26 @@ def create_react_workflow(
289
1342
  if not isinstance(results, list):
290
1343
  results = []
291
1344
 
1345
+ if results:
1346
+ scratchpad["used_tools"] = True
1347
+
1348
+ # Attach observations to the most recent cycle.
1349
+ cycles = scratchpad.get("cycles")
1350
+ last_cycle: Optional[Dict[str, Any]] = None
1351
+ if isinstance(cycles, list):
1352
+ for c in reversed(cycles):
1353
+ if isinstance(c, dict) and int(c.get("i") or -1) == int(scratchpad.get("iteration") or -1):
1354
+ last_cycle = c
1355
+ break
1356
+
1357
+ def _display(v: Any) -> str:
1358
+ if isinstance(v, dict):
1359
+ rendered = v.get("rendered")
1360
+ if isinstance(rendered, str) and rendered.strip():
1361
+ return rendered.strip()
1362
+ return "" if v is None else str(v)
1363
+
1364
+ obs_list: list[dict[str, Any]] = []
292
1365
  for r in results:
293
1366
  if not isinstance(r, dict):
294
1367
  continue
@@ -296,26 +1369,39 @@ def create_react_workflow(
296
1369
  success = bool(r.get("success"))
297
1370
  output = r.get("output", "")
298
1371
  error = r.get("error", "")
299
- rendered = logic.format_observation(
300
- name=name,
301
- output=str(output if success else (error or output)),
302
- success=success,
303
- )
304
- emit("observe", {"tool": name, "result": rendered[:150]})
1372
+ display = _display(output)
1373
+ if not success:
1374
+ display = _display(output) if isinstance(output, dict) else str(error or output)
1375
+ rendered = logic.format_observation(name=name, output=display, success=success)
1376
+ emit("observe", {"tool": name, "success": success, "result": rendered})
1377
+
305
1378
  context["messages"].append(
306
1379
  _new_message(
307
1380
  ctx,
308
1381
  role="tool",
309
1382
  content=rendered,
310
- metadata={
311
- "name": name,
312
- "call_id": r.get("call_id"),
313
- "success": success,
314
- },
1383
+ metadata={"name": name, "call_id": r.get("call_id"), "success": success},
315
1384
  )
316
1385
  )
317
1386
 
1387
+ obs_list.append(
1388
+ {
1389
+ "call_id": r.get("call_id"),
1390
+ "name": name,
1391
+ "success": success,
1392
+ "output": output,
1393
+ "error": error,
1394
+ "rendered": rendered,
1395
+ }
1396
+ )
1397
+
1398
+ if last_cycle is not None:
1399
+ last_cycle["observations"] = obs_list
1400
+
318
1401
  temp.pop("tool_results", None)
1402
+ pending = temp.get("pending_tool_calls", [])
1403
+ if isinstance(pending, list) and pending:
1404
+ return StepPlan(node_id="observe", next_node="act")
319
1405
  temp["pending_tool_calls"] = []
320
1406
  return StepPlan(node_id="observe", next_node="reason")
321
1407
 
@@ -327,9 +1413,7 @@ def create_react_workflow(
327
1413
  response_text = str(user_response.get("response", "") or "")
328
1414
  emit("user_response", {"response": response_text})
329
1415
 
330
- context["messages"].append(
331
- _new_message(ctx, role="user", content=f"[User response]: {response_text}")
332
- )
1416
+ context["messages"].append(_new_message(ctx, role="user", content=f"[User response]: {response_text}"))
333
1417
  temp.pop("user_response", None)
334
1418
 
335
1419
  if temp.get("pending_tool_calls"):
@@ -338,43 +1422,182 @@ def create_react_workflow(
338
1422
 
339
1423
  def done_node(run: RunState, ctx) -> StepPlan:
340
1424
  context, scratchpad, _, temp, limits = ensure_react_vars(run)
1425
+ task = str(context.get("task", "") or "")
341
1426
  answer = str(temp.get("final_answer") or "No answer provided")
1427
+
342
1428
  emit("done", {"answer": answer})
343
1429
 
344
- # Prefer _limits.current_iteration, fall back to scratchpad
1430
+ messages = context.get("messages")
1431
+ if isinstance(messages, list):
1432
+ last = messages[-1] if messages else None
1433
+ last_role = last.get("role") if isinstance(last, dict) else None
1434
+ last_content = last.get("content") if isinstance(last, dict) else None
1435
+ if last_role != "assistant" or str(last_content or "") != answer:
1436
+ messages.append(_new_message(ctx, role="assistant", content=answer, metadata={"kind": "final_answer"}))
1437
+
345
1438
  iterations = int(limits.get("current_iteration", 0) or scratchpad.get("iteration", 0) or 0)
1439
+ report = _render_final_report(task, scratchpad)
346
1440
 
347
1441
  return StepPlan(
348
1442
  node_id="done",
349
1443
  complete_output={
350
1444
  "answer": answer,
1445
+ "report": report,
351
1446
  "iterations": iterations,
352
1447
  "messages": list(context.get("messages") or []),
1448
+ "scratchpad": dict(scratchpad),
353
1449
  },
354
1450
  )
355
1451
 
356
1452
  def max_iterations_node(run: RunState, ctx) -> StepPlan:
357
- context, scratchpad, _, _, limits = ensure_react_vars(run)
358
-
359
- # Prefer _limits, fall back to scratchpad
1453
+ context, scratchpad, runtime_ns, temp, limits = ensure_react_vars(run)
360
1454
  max_iterations = int(limits.get("max_iterations", 0) or scratchpad.get("max_iterations", 25) or 25)
361
1455
  if max_iterations < 1:
362
1456
  max_iterations = 1
363
1457
  emit("max_iterations", {"iterations": max_iterations})
364
1458
 
365
- messages = list(context.get("messages") or [])
366
- last_content = messages[-1]["content"] if messages else "Max iterations reached"
1459
+ # Deterministic conclusion: when we hit the iteration cap, run one tool-free LLM call
1460
+ # to synthesize a final report + next steps while the scratchpad is still in context.
1461
+ resp = temp.get("max_iterations_llm_response")
1462
+ if not isinstance(resp, dict):
1463
+ drained_guidance = _drain_inbox(runtime_ns)
1464
+ conclude_directive = (
1465
+ "You have reached the maximum allowed ReAct iterations.\n"
1466
+ "You MUST stop using tools now and provide a best-effort conclusion.\n\n"
1467
+ "In your response, include:\n"
1468
+ "1) A concise progress report (what you did + key observations).\n"
1469
+ "2) The best current answer you can give based on evidence.\n"
1470
+ "3) Remaining uncertainties / missing info.\n"
1471
+ "4) Next steps: exact actions to finish (files to inspect/edit, commands/tools to run, what to look for).\n\n"
1472
+ "Rules:\n"
1473
+ "- Do NOT call tools.\n"
1474
+ "- Do NOT output tool-call markup (e.g. <tool_call>...</tool_call>).\n"
1475
+ "- Do NOT mention internal scratchpads; just present the report.\n"
1476
+ "- Prefer bullet points and concrete next steps."
1477
+ )
1478
+
1479
+ task = str(context.get("task", "") or "")
1480
+ messages_view = list(context.get("messages") or [])
1481
+
1482
+ req = logic.build_request(
1483
+ task=task,
1484
+ messages=messages_view,
1485
+ guidance="",
1486
+ iteration=max_iterations,
1487
+ max_iterations=max_iterations,
1488
+ vars=run.vars,
1489
+ )
1490
+
1491
+ payload: Dict[str, Any] = {"prompt": ""}
1492
+ sanitized_messages = _sanitize_llm_messages(messages_view)
1493
+ if sanitized_messages:
1494
+ payload["messages"] = sanitized_messages
1495
+ else:
1496
+ task_text = str(task or "").strip()
1497
+ if task_text:
1498
+ payload["prompt"] = task_text
1499
+
1500
+ media = extract_media_from_context(context)
1501
+ if media:
1502
+ payload["media"] = media
1503
+
1504
+ sys_base = str(req.system_prompt or "").strip()
1505
+ sys = _compose_system_prompt(runtime_ns, base=sys_base)
1506
+ block_parts: list[str] = []
1507
+ if drained_guidance:
1508
+ block_parts.append(f"Host guidance:\n{drained_guidance}")
1509
+ block_parts.append(conclude_directive)
1510
+ sys = (f"{sys.rstrip()}\n\n## Max iterations reached\n" + "\n\n".join(block_parts)).strip()
1511
+ scratch_txt = _render_cycles_for_conclusion_prompt(scratchpad)
1512
+ if scratch_txt:
1513
+ sys = f"{sys.rstrip()}\n\n## Scratchpad (ReAct cycles so far)\n{scratch_txt}".strip()
1514
+ if sys:
1515
+ payload["system_prompt"] = sys
1516
+
1517
+ eff_provider = provider if isinstance(provider, str) and provider.strip() else runtime_ns.get("provider")
1518
+ eff_model = model if isinstance(model, str) and model.strip() else runtime_ns.get("model")
1519
+ if isinstance(eff_provider, str) and eff_provider.strip():
1520
+ payload["provider"] = eff_provider.strip()
1521
+ if isinstance(eff_model, str) and eff_model.strip():
1522
+ payload["model"] = eff_model.strip()
1523
+
1524
+ params: Dict[str, Any] = {}
1525
+ max_out = _max_output_tokens(runtime_ns, limits)
1526
+ if isinstance(max_out, int) and max_out > 0:
1527
+ params["max_tokens"] = max_out
1528
+ payload["params"] = runtime_llm_params(runtime_ns, extra=params, default_temperature=0.2)
1529
+
1530
+ return StepPlan(
1531
+ node_id="max_iterations",
1532
+ effect=Effect(type=EffectType.LLM_CALL, payload=payload, result_key="_temp.max_iterations_llm_response"),
1533
+ next_node="max_iterations",
1534
+ )
1535
+
1536
+ # We have a conclusion LLM response. Parse it and complete the run.
1537
+ content, tool_calls = logic.parse_response(resp)
1538
+ answer = str(content or "").strip()
1539
+ temp.pop("max_iterations_llm_response", None)
1540
+
1541
+ # If the model still emitted tool calls, or if it leaked tool-call markup as plain text,
1542
+ # retry once with a stricter instruction.
1543
+ tool_tags = _contains_tool_call_markup(answer)
1544
+ if tool_calls or tool_tags:
1545
+ retries = int(temp.get("max_iterations_conclude_retries", 0) or 0)
1546
+ if retries < 1:
1547
+ temp["max_iterations_conclude_retries"] = retries + 1
1548
+ _push_inbox(
1549
+ runtime_ns,
1550
+ "You are out of iterations and tool use is disabled.\n"
1551
+ "Return ONLY the final report and next steps as plain text.\n"
1552
+ "Do NOT include any tool calls or tool-call markup (e.g. <tool_call>...</tool_call>).",
1553
+ )
1554
+ return StepPlan(node_id="max_iterations", next_node="max_iterations")
1555
+ # Last resort: strip any leaked tool markup so we don't persist it as the final answer.
1556
+ answer = _strip_tool_call_markup(answer).strip()
1557
+
1558
+ if not answer:
1559
+ # Fallback: avoid returning the last tool observation as the "answer".
1560
+ # Provide a deterministic report so users don't lose scratchpad context.
1561
+ scratch_view = _render_cycles_for_conclusion_prompt(scratchpad)
1562
+ parts = [
1563
+ "Max iterations reached.",
1564
+ "I could not produce a final assistant response in time.",
1565
+ ]
1566
+ if scratch_view:
1567
+ parts.append("## Progress (from scratchpad)\n" + scratch_view)
1568
+ parts.append(
1569
+ "## Next steps\n"
1570
+ "- Increase `max_iterations` and rerun, or use `/conclude` earlier to force a wrap-up.\n"
1571
+ "- If you need me to continue, re-run with a higher iteration budget and I will pick up from the report above."
1572
+ )
1573
+ answer = "\n\n".join(parts).strip()
1574
+
1575
+ # Persist final answer into the conversation history (so it shows up in /history and seeds next runs).
1576
+ messages = context.get("messages")
1577
+ if isinstance(messages, list):
1578
+ last = messages[-1] if messages else None
1579
+ last_role = last.get("role") if isinstance(last, dict) else None
1580
+ last_content = last.get("content") if isinstance(last, dict) else None
1581
+ if last_role != "assistant" or str(last_content or "") != answer:
1582
+ messages.append(_new_message(ctx, role="assistant", content=answer, metadata={"kind": "final_answer"}))
1583
+
1584
+ temp["final_answer"] = answer
1585
+ report = _render_final_report(str(context.get("task") or ""), scratchpad)
1586
+
1587
+ iterations = int(limits.get("current_iteration", 0) or scratchpad.get("iteration", 0) or max_iterations)
367
1588
  return StepPlan(
368
1589
  node_id="max_iterations",
369
1590
  complete_output={
370
- "answer": last_content,
371
- "iterations": max_iterations,
372
- "messages": messages,
1591
+ "answer": answer,
1592
+ "report": report,
1593
+ "iterations": iterations,
1594
+ "messages": list(context.get("messages") or []),
1595
+ "scratchpad": dict(scratchpad),
373
1596
  },
374
1597
  )
375
1598
 
376
1599
  return WorkflowSpec(
377
- workflow_id="react_agent",
1600
+ workflow_id=str(workflow_id or "react_agent"),
378
1601
  entry_node="init",
379
1602
  nodes={
380
1603
  "init": init_node,
@@ -387,4 +1610,3 @@ def create_react_workflow(
387
1610
  "max_iterations": max_iterations_node,
388
1611
  },
389
1612
  )
390
-