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.
- reactifact/__init__.py +96 -0
- reactifact/__main__.py +10 -0
- reactifact/_extras.py +36 -0
- reactifact/agents.py +173 -0
- reactifact/artifacts.py +130 -0
- reactifact/branching.py +255 -0
- reactifact/budget.py +41 -0
- reactifact/chat.py +373 -0
- reactifact/checkpoints.py +329 -0
- reactifact/cli/__init__.py +73 -0
- reactifact/cli/branch.py +77 -0
- reactifact/cli/common.py +67 -0
- reactifact/cli/context.py +53 -0
- reactifact/cli/graph.py +21 -0
- reactifact/cli/replay.py +69 -0
- reactifact/cli/scenario.py +94 -0
- reactifact/cli/trace.py +45 -0
- reactifact/commit.py +97 -0
- reactifact/commit_log.py +235 -0
- reactifact/consume.py +96 -0
- reactifact/context.py +599 -0
- reactifact/effects.py +232 -0
- reactifact/eval.py +319 -0
- reactifact/events.py +34 -0
- reactifact/interrupt.py +22 -0
- reactifact/llm_agent.py +172 -0
- reactifact/operations.py +192 -0
- reactifact/patches.py +112 -0
- reactifact/produce.py +226 -0
- reactifact/prompts.py +111 -0
- reactifact/providers/__init__.py +153 -0
- reactifact/providers/_retry.py +61 -0
- reactifact/providers/anthropic.py +182 -0
- reactifact/providers/azure.py +31 -0
- reactifact/providers/cerebras.py +11 -0
- reactifact/providers/chat.py +417 -0
- reactifact/providers/contracts.py +105 -0
- reactifact/providers/deepseek.py +11 -0
- reactifact/providers/fake.py +40 -0
- reactifact/providers/fireworks.py +17 -0
- reactifact/providers/gemini.py +284 -0
- reactifact/providers/github_models.py +13 -0
- reactifact/providers/groq.py +18 -0
- reactifact/providers/image.py +157 -0
- reactifact/providers/mistral.py +17 -0
- reactifact/providers/nvidia.py +18 -0
- reactifact/providers/ollama.py +18 -0
- reactifact/providers/openai.py +44 -0
- reactifact/providers/openrouter.py +70 -0
- reactifact/providers/perplexity.py +11 -0
- reactifact/providers/qwen.py +17 -0
- reactifact/providers/speech.py +347 -0
- reactifact/providers/together.py +17 -0
- reactifact/providers/video.py +407 -0
- reactifact/providers/xai.py +11 -0
- reactifact/providers/zai.py +11 -0
- reactifact/py.typed +0 -0
- reactifact/recipes/__init__.py +63 -0
- reactifact/recipes/inputs.py +34 -0
- reactifact/recipes/memory.py +166 -0
- reactifact/recipes/resolve.py +51 -0
- reactifact/recipes/rollback.py +87 -0
- reactifact/recipes/search.py +81 -0
- reactifact/recipes/skills.py +108 -0
- reactifact/recipes/status.py +79 -0
- reactifact/recipes/text.py +202 -0
- reactifact/relations.py +104 -0
- reactifact/replay.py +187 -0
- reactifact/resources.py +45 -0
- reactifact/runtime.py +498 -0
- reactifact/scheduler.py +188 -0
- reactifact/session.py +75 -0
- reactifact/sources.py +498 -0
- reactifact/streaming.py +58 -0
- reactifact/structured.py +245 -0
- reactifact/testing/__init__.py +48 -0
- reactifact/testing/assertions.py +326 -0
- reactifact/testing/exceptions.py +27 -0
- reactifact/testing/fault.py +164 -0
- reactifact/testing/lab.py +350 -0
- reactifact/testing/mock.py +166 -0
- reactifact/testing/record.py +50 -0
- reactifact/testing/registry.py +87 -0
- reactifact/tool_use.py +528 -0
- reactifact/tools.py +111 -0
- reactifact/tracing/__init__.py +29 -0
- reactifact/tracing/langfuse.py +125 -0
- reactifact/tracing/models.py +93 -0
- reactifact/tracing/postgres.py +220 -0
- reactifact/tracing/store.py +254 -0
- reactifact/tracing/templates/ui.html +196 -0
- reactifact/tracing/templates/ui_run.html +264 -0
- reactifact/tracing/tracer.py +370 -0
- reactifact/tracing/web.py +117 -0
- reactifact/triggers.py +41 -0
- reactifact/viz.py +248 -0
- reactifact/web.py +117 -0
- reactifact-0.6.0.dist-info/METADATA +226 -0
- reactifact-0.6.0.dist-info/RECORD +103 -0
- reactifact-0.6.0.dist-info/WHEEL +5 -0
- reactifact-0.6.0.dist-info/entry_points.txt +2 -0
- reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
- 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 "—")
|