reactifact 0.6.0__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.
Files changed (103) hide show
  1. reactifact/__init__.py +96 -0
  2. reactifact/__main__.py +10 -0
  3. reactifact/_extras.py +36 -0
  4. reactifact/agents.py +173 -0
  5. reactifact/artifacts.py +130 -0
  6. reactifact/branching.py +255 -0
  7. reactifact/budget.py +41 -0
  8. reactifact/chat.py +373 -0
  9. reactifact/checkpoints.py +329 -0
  10. reactifact/cli/__init__.py +73 -0
  11. reactifact/cli/branch.py +77 -0
  12. reactifact/cli/common.py +67 -0
  13. reactifact/cli/context.py +53 -0
  14. reactifact/cli/graph.py +21 -0
  15. reactifact/cli/replay.py +69 -0
  16. reactifact/cli/scenario.py +94 -0
  17. reactifact/cli/trace.py +45 -0
  18. reactifact/commit.py +97 -0
  19. reactifact/commit_log.py +235 -0
  20. reactifact/consume.py +96 -0
  21. reactifact/context.py +599 -0
  22. reactifact/effects.py +232 -0
  23. reactifact/eval.py +319 -0
  24. reactifact/events.py +34 -0
  25. reactifact/interrupt.py +22 -0
  26. reactifact/llm_agent.py +172 -0
  27. reactifact/operations.py +192 -0
  28. reactifact/patches.py +112 -0
  29. reactifact/produce.py +226 -0
  30. reactifact/prompts.py +111 -0
  31. reactifact/providers/__init__.py +153 -0
  32. reactifact/providers/_retry.py +61 -0
  33. reactifact/providers/anthropic.py +182 -0
  34. reactifact/providers/azure.py +31 -0
  35. reactifact/providers/cerebras.py +11 -0
  36. reactifact/providers/chat.py +417 -0
  37. reactifact/providers/contracts.py +105 -0
  38. reactifact/providers/deepseek.py +11 -0
  39. reactifact/providers/fake.py +40 -0
  40. reactifact/providers/fireworks.py +17 -0
  41. reactifact/providers/gemini.py +284 -0
  42. reactifact/providers/github_models.py +13 -0
  43. reactifact/providers/groq.py +18 -0
  44. reactifact/providers/image.py +157 -0
  45. reactifact/providers/mistral.py +17 -0
  46. reactifact/providers/nvidia.py +18 -0
  47. reactifact/providers/ollama.py +18 -0
  48. reactifact/providers/openai.py +44 -0
  49. reactifact/providers/openrouter.py +70 -0
  50. reactifact/providers/perplexity.py +11 -0
  51. reactifact/providers/qwen.py +17 -0
  52. reactifact/providers/speech.py +347 -0
  53. reactifact/providers/together.py +17 -0
  54. reactifact/providers/video.py +407 -0
  55. reactifact/providers/xai.py +11 -0
  56. reactifact/providers/zai.py +11 -0
  57. reactifact/py.typed +0 -0
  58. reactifact/recipes/__init__.py +63 -0
  59. reactifact/recipes/inputs.py +34 -0
  60. reactifact/recipes/memory.py +166 -0
  61. reactifact/recipes/resolve.py +51 -0
  62. reactifact/recipes/rollback.py +87 -0
  63. reactifact/recipes/search.py +81 -0
  64. reactifact/recipes/skills.py +108 -0
  65. reactifact/recipes/status.py +79 -0
  66. reactifact/recipes/text.py +202 -0
  67. reactifact/relations.py +104 -0
  68. reactifact/replay.py +187 -0
  69. reactifact/resources.py +45 -0
  70. reactifact/runtime.py +498 -0
  71. reactifact/scheduler.py +188 -0
  72. reactifact/session.py +75 -0
  73. reactifact/sources.py +498 -0
  74. reactifact/streaming.py +58 -0
  75. reactifact/structured.py +245 -0
  76. reactifact/testing/__init__.py +48 -0
  77. reactifact/testing/assertions.py +326 -0
  78. reactifact/testing/exceptions.py +27 -0
  79. reactifact/testing/fault.py +164 -0
  80. reactifact/testing/lab.py +350 -0
  81. reactifact/testing/mock.py +166 -0
  82. reactifact/testing/record.py +50 -0
  83. reactifact/testing/registry.py +87 -0
  84. reactifact/tool_use.py +528 -0
  85. reactifact/tools.py +111 -0
  86. reactifact/tracing/__init__.py +29 -0
  87. reactifact/tracing/langfuse.py +125 -0
  88. reactifact/tracing/models.py +93 -0
  89. reactifact/tracing/postgres.py +220 -0
  90. reactifact/tracing/store.py +254 -0
  91. reactifact/tracing/templates/ui.html +196 -0
  92. reactifact/tracing/templates/ui_run.html +264 -0
  93. reactifact/tracing/tracer.py +370 -0
  94. reactifact/tracing/web.py +117 -0
  95. reactifact/triggers.py +41 -0
  96. reactifact/viz.py +248 -0
  97. reactifact/web.py +117 -0
  98. reactifact-0.6.0.dist-info/METADATA +226 -0
  99. reactifact-0.6.0.dist-info/RECORD +103 -0
  100. reactifact-0.6.0.dist-info/WHEEL +5 -0
  101. reactifact-0.6.0.dist-info/entry_points.txt +2 -0
  102. reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
  103. reactifact-0.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,50 @@
1
+ """Record/replay glue for `reactifact.testing` — reuses `reactifact.replay.ReplayLLM` directly.
2
+
3
+ No new recording mechanism: `ReplayLLM` already records/replays LLM calls to
4
+ a JSONL file (`reactifact/replay.py`). This module only decides *whether* to
5
+ wrap `context.resources.llm` with it, based on a scenario's `mode`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Literal, get_args
13
+
14
+ from reactifact.providers import LLMProvider
15
+ from reactifact.replay import ReplayLLM
16
+
17
+ Mode = Literal["live", "record", "replay"]
18
+
19
+ MODE_ENV_VAR = "REACTIFACT_SCENARIO_MODE"
20
+
21
+
22
+ def wrap_llm(
23
+ inner: LLMProvider | None, *, mode: Mode, recording_path: Path
24
+ ) -> LLMProvider | None:
25
+ """Returns `inner` unchanged in `"live"` mode, or a `ReplayLLM` wrapper otherwise."""
26
+ if mode == "live" or inner is None:
27
+ return inner
28
+ return ReplayLLM(
29
+ str(recording_path),
30
+ mode=mode,
31
+ inner=inner,
32
+ model=str(getattr(inner, "model", "") or ""),
33
+ )
34
+
35
+
36
+ def mode_from_env(default: Mode = "live") -> Mode:
37
+ """Reads the scenario mode from `$REACTIFACT_SCENARIO_MODE` (set by `reactifact
38
+ scenario --mode ...`), falling back to `default` when unset.
39
+
40
+ Lets a scenario module stay agnostic of the CLI: build the `ScenarioLab`
41
+ with `mode=mode_from_env()` and the same scenario runs live, records, or
42
+ replays depending only on how it was invoked.
43
+ """
44
+ value = os.environ.get(MODE_ENV_VAR, default)
45
+ if value not in get_args(Mode):
46
+ raise ValueError(
47
+ f"{MODE_ENV_VAR}={value!r} is not a valid mode "
48
+ f"(expected one of {get_args(Mode)})"
49
+ )
50
+ return value # type: ignore[return-value]
@@ -0,0 +1,87 @@
1
+ """`@scenario` registration for the `reactifact scenario` CLI.
2
+
3
+ Scenarios are plain functions (usually `async def`, wrapping one or more
4
+ `ScenarioLab.run()` calls) registered with `@scenario(...)` at import time —
5
+ mirroring how `reactifact.cli.common.load_agents` resolves a module path to
6
+ `Agent` instances, `collect()` resolves a list of module paths to the
7
+ `@scenario`-decorated functions found inside them. There is deliberately no
8
+ directory-scanning convention (no `scenario_*.py` globbing): a scenario
9
+ module is just a module, imported by its dotted path like any other.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ import sys
16
+ from collections.abc import Awaitable, Callable
17
+ from dataclasses import dataclass
18
+
19
+ #: A registered scenario: a human-readable name plus the zero-arg callable
20
+ #: that runs it (raises on failure — `AssertionFailure`/`AssertionError` for a
21
+ #: failed check, `ScenarioSkip` to opt out, anything else counts as an error).
22
+ ScenarioFunc = Callable[[], "Awaitable[None] | None"]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ScenarioCase:
27
+ name: str
28
+ func: ScenarioFunc
29
+
30
+
31
+ _REGISTRY: list[ScenarioCase] = []
32
+
33
+
34
+ def scenario(name: str | None = None) -> Callable[[ScenarioFunc], ScenarioFunc]:
35
+ """Registers a function as a scenario, importable by `collect()`.
36
+
37
+ `name` defaults to the function's `__name__`; give it an explicit,
38
+ descriptive name (e.g. `"repair: estimate prices from the catalog"`) since
39
+ it's what the CLI prints and what `-k`/`--filter` matches against.
40
+ """
41
+
42
+ def decorator(func: ScenarioFunc) -> ScenarioFunc:
43
+ _REGISTRY.append(ScenarioCase(name=name or func.__name__, func=func))
44
+ return func
45
+
46
+ return decorator
47
+
48
+
49
+ def collect(modules: list[str]) -> list[ScenarioCase]:
50
+ """Imports `modules` (dotted paths) and returns every scenario they
51
+ registered, in encounter order. Clears the registry first, so re-running
52
+ `collect()` in the same process (e.g. from tests of this module) doesn't
53
+ accumulate duplicates from a previous import.
54
+
55
+ A plain `import_module` is a no-op for a module already in `sys.modules`
56
+ — its `@scenario` decorators would not re-run against the just-cleared
57
+ registry, silently dropping that module's cases. `_reimport` reloads an
58
+ already-imported module (and, for a package, every already-imported
59
+ submodule — that's where a scenarios package like `examples.repair.
60
+ scenarios` actually keeps its `@scenario` functions) so `collect()` gives
61
+ the same result no matter how many times it's called in one process.
62
+ """
63
+ _REGISTRY.clear()
64
+ for module_name in modules:
65
+ try:
66
+ _reimport(module_name)
67
+ except ModuleNotFoundError as exc:
68
+ raise SystemExit(f"could not import {module_name!r}: {exc}") from exc
69
+ return list(_REGISTRY)
70
+
71
+
72
+ def _reimport(module_name: str) -> None:
73
+ if module_name not in sys.modules:
74
+ importlib.import_module(module_name)
75
+ return
76
+ prefix = f"{module_name}."
77
+ tree = [n for n in sys.modules if n == module_name or n.startswith(prefix)]
78
+ # deepest submodules first, so a package's `from . import child` (run when
79
+ # the package itself reloads) picks up already-fresh children rather than
80
+ # re-triggering their (already-cached) import.
81
+ for name in sorted(tree, key=lambda n: n.count("."), reverse=True):
82
+ module = sys.modules.get(name)
83
+ if module is not None:
84
+ importlib.reload(module)
85
+
86
+
87
+ __all__ = ["ScenarioCase", "collect", "scenario"]
reactifact/tool_use.py ADDED
@@ -0,0 +1,528 @@
1
+ """Tool-use for LLM agents: \"LLM decides → tool → … → answer\" as a Produce.
2
+
3
+ Two loop variants:
4
+ - `ToolUse` — a blocking loop in a single produce (simple, atomic);
5
+ - `ToolUseHITL` — reactive, step by step: can ask the human clarifying
6
+ questions (`type:\"ask\"` → `PendingQuestion`) and resume after the answer
7
+ (§60). Intermediate steps are state (`Observation`).
8
+
9
+ Both end in a `ToolAnswer` artifact (the LLM final answer, §68); your produces
10
+ turn it into domain artifacts. `ToolAnswer.agent` distinguishes answers from
11
+ different LLM agents in one Runtime.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from collections.abc import Callable, Sequence
19
+ from typing import Any, Literal
20
+
21
+ from pydantic import BaseModel, Field
22
+
23
+ from .artifacts import Artifact
24
+ from .context import Context
25
+ from .events import Event
26
+ from .interrupt import PendingQuestion
27
+ from .produce import Produce
28
+ from .structured import structured_llm
29
+ from .tools import Tool
30
+
31
+
32
+ class ToolAnswer(BaseModel):
33
+ """Final LLM answer as an artifact (§68). `agent` — which ToolUse created it."""
34
+
35
+ agent: str = ""
36
+ query_id: str = ""
37
+ text: str = ""
38
+
39
+
40
+ class Observation(BaseModel):
41
+ """A step in the reactive loop: a tool result (tool) or a human answer (user)."""
42
+
43
+ query_id: str
44
+ text: str
45
+ step: int = 0
46
+ source: str = "tool"
47
+ agent: str = ""
48
+
49
+
50
+ class _ToolUseStep(BaseModel):
51
+ """LLM decision inside the blocking loop: call a tool or answer."""
52
+
53
+ type: Literal["tool_call", "answer"]
54
+ tool: str = ""
55
+ args: dict[str, Any] = Field(default_factory=dict)
56
+ text: str = ""
57
+
58
+
59
+ class _ToolUseStepHITL(BaseModel):
60
+ """LLM decision inside the reactive loop: tool / clarify / answer."""
61
+
62
+ type: Literal["tool_call", "answer", "ask"]
63
+ tool: str = ""
64
+ args: dict[str, Any] = Field(default_factory=dict)
65
+ text: str = ""
66
+
67
+
68
+ class _FinalAnswer(BaseModel):
69
+ """Forced answer when the loop hit the step limit."""
70
+
71
+ text: str = ""
72
+
73
+
74
+ class _ToolLoopBase(Produce[ToolAnswer]):
75
+ """Shared plumbing for `ToolUse`/`ToolUseHITL`: tool registry + `_run_tool`.
76
+
77
+ Both loops offer the same tool set the same way and execute a tool call
78
+ identically (unknown tool / destructive / exception → a text result the
79
+ LLM sees, never an exception out of the produce). Everything that differs
80
+ between the two — the decision schema, the prompt rules, how history is
81
+ represented, whether a clarifying question can be asked — stays on the
82
+ subclass; this base only holds what is genuinely identical.
83
+ """
84
+
85
+ artifact_type = ToolAnswer
86
+
87
+ def __init__(
88
+ self,
89
+ system: str,
90
+ tools: Sequence[Tool] | dict[str, Tool],
91
+ *,
92
+ name: str = "llm",
93
+ temperature: float | None = None,
94
+ max_tokens: int | None = None,
95
+ ):
96
+ self.name = name
97
+ self.system = system
98
+ self.tools = (
99
+ {t.name: t for t in tools} if not isinstance(tools, dict) else dict(tools)
100
+ )
101
+ self.temperature = temperature
102
+ self.max_tokens = max_tokens
103
+ super().__init__()
104
+
105
+ async def _run_tool(
106
+ self, context: Context, tool_id: str, args: dict[str, Any]
107
+ ) -> str:
108
+ tool = self.tools.get(tool_id)
109
+ if tool is None:
110
+ available = ", ".join(self.tools)
111
+ return f"Unknown tool '{tool_id}'. Available: {available}"
112
+ if tool.destructive:
113
+ return f"Tool '{tool_id}' is destructive and not offered to the LLM."
114
+ try:
115
+ output = await tool.execute(args)
116
+ except Exception as exc: # noqa: BLE001 — tool failure is returned to the LLM
117
+ return f"Tool '{tool_id}' failed: {exc}"
118
+ if output.error:
119
+ return output.error
120
+ return (
121
+ output.text if output.text else json.dumps(output.data, ensure_ascii=False)
122
+ )
123
+
124
+
125
+ class ToolUse(_ToolLoopBase):
126
+ """Blocking loop \"LLM decides → tool → … → answer\" in a single produce.
127
+
128
+ Simple, no HITL: the LLM either calls a tool or answers. The logic lives here,
129
+ not in the container agent. Destructive tools are not offered to the LLM.
130
+ """
131
+
132
+ def __init__(
133
+ self,
134
+ system: str,
135
+ tools: Sequence[Tool] | dict[str, Tool],
136
+ *,
137
+ name: str = "llm",
138
+ max_steps: int = 8,
139
+ temperature: float | None = None,
140
+ max_tokens: int | None = None,
141
+ ):
142
+ super().__init__(
143
+ system, tools, name=name, temperature=temperature, max_tokens=max_tokens
144
+ )
145
+ self.max_steps = max_steps
146
+
147
+ async def produce(
148
+ self,
149
+ context: Context,
150
+ inputs: list[Artifact[Any]],
151
+ event: Event | None = None,
152
+ ) -> None:
153
+ artifact = context.get(event.artifact_id) if event is not None else None
154
+ if artifact is None or isinstance(artifact.data, ToolAnswer):
155
+ return None # final answer is handled by user produces
156
+ goal = getattr(artifact.data, "text", "") or ""
157
+ text = await self._loop(context, goal)
158
+ self.effects.create(
159
+ ToolAnswer(agent=self.name, query_id=artifact.id, text=text)
160
+ )
161
+ return None
162
+
163
+ async def _loop(self, context: Context, goal: str) -> str:
164
+ history: list[str] = []
165
+ budget = context.resources.get("budget")
166
+ max_tool_calls = budget.max_tool_calls if budget is not None else None
167
+ # Runtime only enforces Budget.max_seconds *between* agent runs
168
+ # (Runtime._budget_exhausted): this loop makes several LLM/tool
169
+ # round-trips inside one produce(), so without its own check here a
170
+ # slow provider could blow well past the time budget before the
171
+ # runtime ever gets a chance to see it.
172
+ deadline = context.resources.get("budget_deadline")
173
+ executed = 0
174
+ context.announce("Deciding next action…", kind="agent", agent=self.name)
175
+ for _ in range(self.max_steps):
176
+ if deadline is not None and time.monotonic() >= deadline:
177
+ break # time budget exhausted — fall through to the forced answer
178
+ decision = await structured_llm(
179
+ context,
180
+ schema=_ToolUseStep,
181
+ system=self._system_prompt(),
182
+ user=self._user_prompt(goal, history),
183
+ temperature=self.temperature,
184
+ max_tokens=self.max_tokens,
185
+ )
186
+ if decision is None:
187
+ return "Could not reach a decision."
188
+ if decision.type == "answer":
189
+ return decision.text
190
+ if not decision.tool:
191
+ return "Tool not specified."
192
+ if max_tool_calls is not None and executed >= max_tool_calls:
193
+ history.append(
194
+ "Tool budget exhausted; answer based on the available data."
195
+ )
196
+ continue
197
+ context.announce(
198
+ f"Calling tool '{decision.tool}'…", kind="agent", tool=decision.tool
199
+ )
200
+ result = await self._run_tool(context, decision.tool, decision.args)
201
+ executed += 1
202
+ history.append(
203
+ f"tool_call: {decision.tool}({json.dumps(decision.args, ensure_ascii=False)})\n"
204
+ f"result: {result}"
205
+ )
206
+ # Loop hit the step limit or the time budget: force the LLM to answer
207
+ # based on the data gathered so far.
208
+ forced = await structured_llm(
209
+ context,
210
+ schema=_FinalAnswer,
211
+ system="Answer now based on the available data. "
212
+ 'Reply with strict JSON: {"text":"..."}',
213
+ user=self._user_prompt(goal, history),
214
+ temperature=self.temperature,
215
+ max_tokens=self.max_tokens,
216
+ )
217
+ if forced is not None and forced.text:
218
+ return forced.text
219
+ return "Step limit reached; answer based on the available data."
220
+
221
+ def _system_prompt(self) -> str:
222
+ usable = [t for t in self.tools.values() if not t.destructive]
223
+ schemas = "\n".join(
224
+ f"- {t.name}: {t.description}\n args: {json.dumps(t.schema, ensure_ascii=False)}"
225
+ for t in usable
226
+ )
227
+ return (
228
+ f"{self.system}\n\n"
229
+ f"Available tools:\n{schemas}\n\n"
230
+ "You work in a loop, one step at a time. Each step reply with strict "
231
+ "JSON matching this schema: "
232
+ '{"type":"tool_call","tool":"<name>","args":{...}} — call a tool, or '
233
+ '{"type":"answer","text":"..."} — final answer.\n'
234
+ "Rules:\n"
235
+ "- Call at most one tool per step, and never call the same tool twice "
236
+ "in a row.\n"
237
+ "- After a tool result, give the final answer on the next step, unless "
238
+ "the result is clearly insufficient.\n"
239
+ "- Call a tool only when you lack the information; finish within at "
240
+ "most 3 tool calls — prefer answering over extra calls."
241
+ )
242
+
243
+ @staticmethod
244
+ def _user_prompt(goal: str, history: list[str]) -> str:
245
+ results = "\n\n".join(history)
246
+ return f"Goal: {goal}\n\nTool results so far:\n{results or '—'}"
247
+
248
+
249
+ class ToolUseHITL(_ToolLoopBase):
250
+ """Reactive loop: step by step, can ask the human (HITL, §60).
251
+
252
+ The LLM may answer (`answer`), call a tool (`tool_call`, result goes into an
253
+ `Observation`), or ask a clarifying question (`ask` → `PendingQuestion`).
254
+ The human answer comes back into the loop as `Observation(source="user")`.
255
+
256
+ `_history`/the `ask` dedup check filter `context.list_artifacts(Observation
257
+ | PendingQuestion)` by `query_id` in Python — O(count of that type in the
258
+ whole context), not indexed by `query_id`. Bounded per conversation
259
+ (`max_steps`, `max_asks`), so this is only a real cost if *many*
260
+ long-running conversations share one `Context` — the standard
261
+ one-`Context`-per-session pattern (`SessionStore`, every example in this
262
+ repo) keeps each conversation's own artifact count small regardless of
263
+ how many sessions exist. A real fix would need `RelationGraph` indexed by
264
+ `source_id` (it currently isn't either) plus linking each Observation to
265
+ its goal artifact instead of filtering by field — deliberately not done
266
+ here; flag it if you're sharing one long-lived `Context` across many
267
+ concurrent tool-use conversations.
268
+ """
269
+
270
+ def __init__(
271
+ self,
272
+ system: str,
273
+ tools: Sequence[Tool] | dict[str, Tool],
274
+ *,
275
+ name: str = "llm",
276
+ max_steps: int = 8,
277
+ max_asks: int = 2,
278
+ resume_announce: Callable[[str], str] | None = None,
279
+ temperature: float | None = None,
280
+ max_tokens: int | None = None,
281
+ ):
282
+ super().__init__(
283
+ system, tools, name=name, temperature=temperature, max_tokens=max_tokens
284
+ )
285
+ self.max_steps = max_steps
286
+ self.max_asks = max_asks
287
+ # App callback: human answer → status message (kind="status").
288
+ self.resume_announce = resume_announce
289
+
290
+ async def produce(
291
+ self,
292
+ context: Context,
293
+ inputs: list[Artifact[Any]],
294
+ event: Event | None = None,
295
+ ) -> None:
296
+ resolved = self._resolve(context, event)
297
+ if resolved is None:
298
+ return None
299
+ qid, kind, extra = resolved
300
+ goal = self._goal(context, qid)
301
+ history = self._history(context, qid)
302
+
303
+ if kind == "resume":
304
+ # human answer to a clarify → user observation, continue.
305
+ # Status from the app (kind="status"): text authored by the app.
306
+ if self.resume_announce is not None:
307
+ context.announce(
308
+ self.resume_announce(extra), kind="status", agent=self.name
309
+ )
310
+ self.effects.create(
311
+ Observation(
312
+ query_id=qid,
313
+ step=len(history) + 1,
314
+ text=extra,
315
+ source="user",
316
+ agent=self.name,
317
+ )
318
+ )
319
+ return None
320
+
321
+ if len(history) >= self.max_steps:
322
+ await self._forced_answer(context, qid, goal, history)
323
+ return None
324
+
325
+ decision = await structured_llm(
326
+ context,
327
+ schema=_ToolUseStepHITL,
328
+ system=self._system_prompt(),
329
+ user=self._user_prompt(goal, history),
330
+ temperature=self.temperature,
331
+ max_tokens=self.max_tokens,
332
+ )
333
+ if decision is None:
334
+ self._answer(qid, "Could not reach a decision.")
335
+ return None
336
+ if decision.type == "answer":
337
+ self._answer(qid, decision.text)
338
+ return None
339
+ if decision.type == "ask":
340
+ if not decision.text:
341
+ self._answer(qid, "Question not specified.")
342
+ return None
343
+ asked = [
344
+ q
345
+ for q in context.list_artifacts(PendingQuestion)
346
+ if q.data.notes.get("query_id") == qid
347
+ and q.data.question == decision.text
348
+ ]
349
+ if asked:
350
+ answers = [q.data.resolution for q in asked if q.data.answered]
351
+ if not answers:
352
+ # the same question was already asked and awaits an answer
353
+ return None
354
+ # LLM asks the same thing again — nudge it to continue
355
+ self.effects.create(
356
+ Observation(
357
+ query_id=qid,
358
+ step=len(history) + 1,
359
+ text=(
360
+ f"You already asked '{decision.text}' and the user "
361
+ f"answered: {answers[-1]}. Use that answer and "
362
+ "proceed to a final answer."
363
+ ),
364
+ source="tool",
365
+ agent=self.name,
366
+ )
367
+ )
368
+ return None
369
+ asked_count = len(
370
+ [
371
+ q
372
+ for q in context.list_artifacts(PendingQuestion)
373
+ if q.data.notes.get("query_id") == qid
374
+ ]
375
+ )
376
+ if asked_count >= self.max_asks:
377
+ # no more questions — continue with what we have
378
+ self.effects.create(
379
+ Observation(
380
+ query_id=qid,
381
+ step=len(history) + 1,
382
+ text=(
383
+ "You have already asked enough clarifying questions. "
384
+ "Proceed with the tool call using the available values; "
385
+ "if a value is missing, use a reasonable default and "
386
+ "note it in the final answer."
387
+ ),
388
+ source="tool",
389
+ agent=self.name,
390
+ )
391
+ )
392
+ return None
393
+ self.effects.ask(
394
+ decision.text,
395
+ kind="clarify",
396
+ notes={"query_id": qid, "agent": self.name},
397
+ )
398
+ return None
399
+ if not decision.tool:
400
+ self._answer(qid, "Tool not specified.")
401
+ return None
402
+
403
+ tool_history = [o for o in history if o.source == "tool"]
404
+ budget = context.resources.get("budget")
405
+ max_tool_calls = budget.max_tool_calls if budget is not None else None
406
+ if max_tool_calls is not None and len(tool_history) >= max_tool_calls:
407
+ self.effects.create(
408
+ Observation(
409
+ query_id=qid,
410
+ step=len(history) + 1,
411
+ text=(
412
+ f"Tool budget ({max_tool_calls}) exhausted; "
413
+ "answer based on the available data."
414
+ ),
415
+ source="tool",
416
+ agent=self.name,
417
+ )
418
+ )
419
+ return None
420
+ context.announce(
421
+ f"Calling tool '{decision.tool}'…", kind="agent", tool=decision.tool
422
+ )
423
+ result = await self._run_tool(context, decision.tool, decision.args)
424
+ self.effects.create(
425
+ Observation(
426
+ query_id=qid,
427
+ step=len(history) + 1,
428
+ text=result,
429
+ source="tool",
430
+ agent=self.name,
431
+ )
432
+ )
433
+ return None
434
+
435
+ def _answer(self, qid: str, text: str) -> None:
436
+ self.effects.create(ToolAnswer(agent=self.name, query_id=qid, text=text))
437
+
438
+ def _resolve(
439
+ self, context: Context, event: Event | None
440
+ ) -> tuple[str, str, str] | None:
441
+ """(query_id, kind, extra): start / continue / resume (human answer)."""
442
+ artifact = context.get(event.artifact_id) if event is not None else None
443
+ if artifact is None:
444
+ return None
445
+ data = artifact.data
446
+ if isinstance(data, Observation):
447
+ return data.query_id, "continue", ""
448
+ if isinstance(data, PendingQuestion):
449
+ if not data.answered:
450
+ return None # waiting for the human answer
451
+ qid = data.notes.get("query_id")
452
+ if not qid:
453
+ return None
454
+ return qid, "resume", data.resolution or ""
455
+ if isinstance(data, ToolAnswer):
456
+ return None # final answer is handled by user produces
457
+ return artifact.id, "start", ""
458
+
459
+ @staticmethod
460
+ def _goal(context: Context, qid: str) -> str:
461
+ artifact = context.get(qid)
462
+ if artifact is None:
463
+ return ""
464
+ return getattr(artifact.data, "text", "") or ""
465
+
466
+ @staticmethod
467
+ def _history(context: Context, qid: str) -> list[Observation]:
468
+ observations = [
469
+ o for o in context.list_artifacts(Observation) if o.data.query_id == qid
470
+ ]
471
+ observations.sort(key=lambda o: o.data.step)
472
+ return [o.data for o in observations]
473
+
474
+ async def _forced_answer(
475
+ self, context: Context, qid: str, goal: str, history: list[Observation]
476
+ ) -> None:
477
+ forced = await structured_llm(
478
+ context,
479
+ schema=_FinalAnswer,
480
+ system="Answer now based on the available data. "
481
+ 'Reply with strict JSON: {"text":"..."}',
482
+ user=self._user_prompt(goal, history),
483
+ temperature=self.temperature,
484
+ max_tokens=self.max_tokens,
485
+ )
486
+ text = (
487
+ forced.text
488
+ if forced is not None and forced.text
489
+ else "Step limit reached; answer based on the available data."
490
+ )
491
+ self._answer(qid, text)
492
+
493
+ def _system_prompt(self) -> str:
494
+ usable = [t for t in self.tools.values() if not t.destructive]
495
+ schemas = "\n".join(
496
+ f"- {t.name}: {t.description}\n args: {json.dumps(t.schema, ensure_ascii=False)}"
497
+ for t in usable
498
+ )
499
+ return (
500
+ f"{self.system}\n\n"
501
+ f"Available tools:\n{schemas}\n\n"
502
+ "You work in a loop, one step at a time. Each step reply with strict "
503
+ "JSON matching this schema: "
504
+ '{"type":"tool_call","tool":"<name>","args":{...}} — call a tool, '
505
+ '{"type":"ask","text":"..."} — ask the user a clarifying question, or '
506
+ '{"type":"answer","text":"..."} — final answer.\n'
507
+ "Rules:\n"
508
+ "- If you lack context a tool needs (e.g. namespace, repository, role), "
509
+ "ask the user via type:ask — never guess or invent it.\n"
510
+ "- Ask at most one question per missing value, and NEVER ask for the "
511
+ "same value twice. Once the user answers, use that answer as-is and "
512
+ "continue with the tool call — proceed even if the answer is short or "
513
+ "unusual.\n"
514
+ "- Call at most one tool per step, and never call the same tool twice "
515
+ "in a row.\n"
516
+ "- After a tool result, give the final answer on the next step, unless "
517
+ "the result is clearly insufficient.\n"
518
+ "- Call a tool only when you lack the information; finish within at "
519
+ "most 3 tool calls — prefer answering over extra calls."
520
+ )
521
+
522
+ @staticmethod
523
+ def _user_prompt(goal: str, history: list[Observation]) -> str:
524
+ parts = [
525
+ f"user answer: {o.text}" if o.source == "user" else f"tool result: {o.text}"
526
+ for o in history
527
+ ]
528
+ return f"Goal: {goal}\n\nContext so far:\n" + ("\n".join(parts) or "—")