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.
- abstractagent/adapters/__init__.py +2 -1
- abstractagent/adapters/codeact_runtime.py +907 -60
- abstractagent/adapters/generation_params.py +82 -0
- abstractagent/adapters/media.py +45 -0
- abstractagent/adapters/memact_runtime.py +959 -0
- abstractagent/adapters/react_runtime.py +1357 -135
- abstractagent/agents/__init__.py +4 -0
- abstractagent/agents/base.py +89 -1
- abstractagent/agents/codeact.py +125 -18
- abstractagent/agents/memact.py +280 -0
- abstractagent/agents/react.py +129 -18
- abstractagent/logic/__init__.py +2 -0
- abstractagent/logic/builtins.py +270 -5
- abstractagent/logic/codeact.py +91 -81
- abstractagent/logic/memact.py +128 -0
- abstractagent/logic/react.py +91 -50
- abstractagent/repl.py +24 -447
- abstractagent/scripts/__init__.py +5 -0
- abstractagent/scripts/lmstudio_tool_eval.py +426 -0
- abstractagent/tools/__init__.py +9 -0
- abstractagent-0.3.1.dist-info/METADATA +112 -0
- abstractagent-0.3.1.dist-info/RECORD +33 -0
- {abstractagent-0.2.0.dist-info → abstractagent-0.3.1.dist-info}/WHEEL +1 -1
- abstractagent/ui/__init__.py +0 -5
- abstractagent/ui/question.py +0 -197
- abstractagent-0.2.0.dist-info/METADATA +0 -134
- abstractagent-0.2.0.dist-info/RECORD +0 -28
- {abstractagent-0.2.0.dist-info → abstractagent-0.3.1.dist-info}/entry_points.txt +0 -0
- {abstractagent-0.2.0.dist-info → abstractagent-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {abstractagent-0.2.0.dist-info → abstractagent-0.3.1.dist-info}/top_level.txt +0 -0
|
@@ -1,15 +1,31 @@
|
|
|
1
|
-
"""AbstractRuntime adapter for ReAct
|
|
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":
|
|
57
|
+
"metadata": meta,
|
|
37
58
|
}
|
|
38
59
|
|
|
39
60
|
|
|
40
|
-
def
|
|
41
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
messages
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
runtime_ns
|
|
133
|
-
|
|
134
|
-
|
|
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,
|
|
842
|
+
context, scratchpad, runtime_ns, temp, limits = ensure_react_vars(run)
|
|
139
843
|
|
|
140
|
-
#
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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=
|
|
882
|
+
messages=messages_view,
|
|
172
883
|
guidance=guidance,
|
|
173
|
-
iteration=iteration
|
|
884
|
+
iteration=iteration,
|
|
174
885
|
max_iterations=max_iterations,
|
|
175
|
-
vars=run.vars,
|
|
886
|
+
vars=run.vars,
|
|
176
887
|
)
|
|
177
888
|
|
|
178
|
-
emit("reason", {"iteration": iteration
|
|
889
|
+
emit("reason", {"iteration": iteration, "max_iterations": max_iterations, "has_guidance": bool(guidance)})
|
|
179
890
|
|
|
180
|
-
payload = {"prompt":
|
|
181
|
-
|
|
182
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
"
|
|
970
|
+
"content": str(content or ""),
|
|
971
|
+
"reasoning": reasoning_text,
|
|
206
972
|
},
|
|
207
973
|
)
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
1107
|
+
pending = temp.get("pending_tool_calls", [])
|
|
1108
|
+
if not isinstance(pending, list):
|
|
1109
|
+
pending = []
|
|
225
1110
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
result_key="_temp.
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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,
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
)
|
|
304
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
366
|
-
|
|
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":
|
|
371
|
-
"
|
|
372
|
-
"
|
|
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
|
-
|