Skip to content

Commit e3df0ce

Browse files
Add conditional tool dependencies (#35)
* Add tool prerequisite foundation: ToolDef.prerequisites, StepTracker arg tracking, StepEnforcer prereq checking Adds the data model and enforcement logic for conditional tool dependencies (e.g. "must call read_file before edit_file"). Backwards-compatible — all new fields are optional with safe defaults. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Wire prerequisite enforcement into runner with PrerequisiteError and PREREQUISITE_NUDGE Runner builds prereq dict from ToolDefs, checks before execution, blocks batch on violation with nudge, raises PrerequisiteError on exhaustion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add PREREQUISITE_NUDGE to compaction and validate prereq tool names in Workflow TieredCompact drops prerequisite nudges in all three phases (same priority as step nudges). Workflow.__post_init__ validates prerequisite tool names exist in the tools dict. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add compaction, workflow validation, and tests for tool prerequisites TieredCompact drops PREREQUISITE_NUDGE in all phases. Workflow validates prereq tool names exist. 26 new tests covering name-only, arg-matched, mixed prereqs, batch blocking, exhaustion, runner integration, and nudge templates. 703 total tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1280237 commit e3df0ce

15 files changed

Lines changed: 587 additions & 15 deletions

File tree

src/forge/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
ForgeError,
4747
HardwareDetectionError,
4848
MaxIterationsError,
49+
PrerequisiteError,
4950
StepEnforcementError,
5051
StreamError,
5152
ThinkingNotSupportedError,
@@ -124,6 +125,7 @@
124125
"ForgeError",
125126
"HardwareDetectionError",
126127
"MaxIterationsError",
128+
"PrerequisiteError",
127129
"StepEnforcementError",
128130
"StreamError",
129131
"ThinkingNotSupportedError",

src/forge/context/strategies.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ def _phase1(
210210
if 2 <= i < eligible_end:
211211
if msg.metadata.type in (
212212
MessageType.STEP_NUDGE,
213+
MessageType.PREREQUISITE_NUDGE,
213214
MessageType.RETRY_NUDGE,
214215
):
215216
continue
@@ -237,6 +238,7 @@ def _phase2(
237238
if 2 <= i < eligible_end:
238239
if msg.metadata.type in (
239240
MessageType.STEP_NUDGE,
241+
MessageType.PREREQUISITE_NUDGE,
240242
MessageType.RETRY_NUDGE,
241243
MessageType.TOOL_RESULT,
242244
):
@@ -253,6 +255,7 @@ def _phase3(
253255
if 2 <= i < eligible_end:
254256
if msg.metadata.type in (
255257
MessageType.STEP_NUDGE,
258+
MessageType.PREREQUISITE_NUDGE,
256259
MessageType.RETRY_NUDGE,
257260
MessageType.TOOL_RESULT,
258261
MessageType.REASONING,

src/forge/core/inference.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"retry": MessageType.RETRY_NUDGE,
2626
"unknown_tool": MessageType.RETRY_NUDGE,
2727
"step": MessageType.STEP_NUDGE,
28+
"prerequisite": MessageType.PREREQUISITE_NUDGE,
2829
}
2930

3031

src/forge/core/messages.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class MessageType(str, Enum):
2727
REASONING = "reasoning"
2828
TEXT_RESPONSE = "text_response"
2929
STEP_NUDGE = "step_nudge"
30+
PREREQUISITE_NUDGE = "prerequisite_nudge"
3031
RETRY_NUDGE = "retry_nudge"
3132
CONTEXT_WARNING = "context_warning"
3233
SUMMARY = "summary"

src/forge/core/runner.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from forge.core.inference import _NUDGE_KIND_TO_TYPE, _build_tool_call_infos, run_inference
1313
from forge.core.messages import Message, MessageMeta, MessageRole, MessageType, ToolCallInfo
1414
from forge.core.workflow import ToolCall, TextResponse, Workflow, ToolSpec
15-
from forge.errors import MaxIterationsError, StepEnforcementError, ToolCallError, ToolExecutionError, ToolResolutionError
15+
from forge.errors import MaxIterationsError, PrerequisiteError, StepEnforcementError, ToolCallError, ToolExecutionError, ToolResolutionError
1616
from forge.guardrails import ErrorTracker, ResponseValidator, StepEnforcer
1717

1818

@@ -121,9 +121,15 @@ def _emit(msg: Message) -> None:
121121
# Step 2 — Initialize guardrail middleware
122122
tool_names = list(workflow.tools.keys())
123123
validator = ResponseValidator(tool_names, rescue_enabled=self.rescue_enabled)
124+
tool_prerequisites = {
125+
name: td.prerequisites
126+
for name, td in workflow.tools.items()
127+
if td.prerequisites
128+
}
124129
step_enforcer = StepEnforcer(
125130
required_steps=workflow.required_steps,
126131
terminal_tool=workflow.terminal_tool,
132+
tool_prerequisites=tool_prerequisites,
127133
)
128134
error_tracker = ErrorTracker(
129135
max_retries=self.max_retries_per_step,
@@ -206,6 +212,46 @@ def _emit(msg: Message) -> None:
206212
))
207213
continue
208214

215+
# 3b.2 — Check prerequisites
216+
prereq_check = step_enforcer.check_prerequisites(tool_calls)
217+
218+
if prereq_check.needs_nudge:
219+
if step_enforcer.prereq_exhausted:
220+
# Find the first violating tool for the error
221+
for tc in tool_calls:
222+
prereqs = tool_prerequisites.get(tc.tool)
223+
if prereqs:
224+
result = step_enforcer._tracker.check_prerequisites(
225+
tc.tool, tc.args, prereqs,
226+
)
227+
if not result.satisfied:
228+
raise PrerequisiteError(
229+
tool_name=tc.tool,
230+
violations=step_enforcer.prereq_violations,
231+
missing_prereqs=result.missing,
232+
)
233+
if tool_calls[0].reasoning:
234+
_emit(Message(
235+
MessageRole.ASSISTANT,
236+
tool_calls[0].reasoning,
237+
MessageMeta(MessageType.REASONING, step_index=iteration),
238+
))
239+
tc_infos, tool_call_counter = _build_tool_call_infos(tool_calls, tool_call_counter)
240+
_emit(Message(
241+
MessageRole.ASSISTANT,
242+
"",
243+
MessageMeta(MessageType.TOOL_CALL, step_index=iteration),
244+
tool_calls=tc_infos,
245+
))
246+
nudge = prereq_check.nudge
247+
nudge_type = _NUDGE_KIND_TO_TYPE[nudge.kind]
248+
_emit(Message(
249+
MessageRole.USER,
250+
nudge.content,
251+
MessageMeta(nudge_type, step_index=iteration),
252+
))
253+
continue
254+
209255
# 3c — Execute all tool calls in the batch
210256
tc_infos, tool_call_counter = _build_tool_call_infos(tool_calls, tool_call_counter)
211257
call_ids = [tc.call_id for tc in tc_infos]
@@ -262,7 +308,7 @@ def _emit(msg: Message) -> None:
262308
continue
263309

264310
# Success
265-
step_enforcer.record(tc.tool)
311+
step_enforcer.record(tc.tool, tc.args)
266312
result_str = result_val if isinstance(result_val, str) else json.dumps(result_val)
267313
_emit(Message(
268314
MessageRole.TOOL,
@@ -287,6 +333,7 @@ def _emit(msg: Message) -> None:
287333
else:
288334
error_tracker.reset_errors()
289335
step_enforcer.reset_premature()
336+
step_enforcer.reset_prereq_violations()
290337

291338
# 3e — If terminal tool was in the batch and succeeded, return
292339
if terminal_result is not None and not isinstance(terminal_result, Exception):

src/forge/core/steps.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,46 @@
1-
"""Required-step tracking for workflow enforcement."""
1+
"""Required-step tracking and prerequisite enforcement."""
22

33
from __future__ import annotations
44

55
from dataclasses import dataclass, field
6+
from typing import Any
7+
8+
9+
@dataclass
10+
class PrerequisiteCheck:
11+
"""Result of checking prerequisites for a tool call.
12+
13+
If ``satisfied`` is False, ``missing`` lists the prerequisite tool names
14+
that have not been called (or not called with matching args).
15+
"""
16+
17+
satisfied: bool
18+
missing: list[str]
619

720

821
@dataclass
922
class StepTracker:
10-
"""Tracks which required steps have been completed.
23+
"""Tracks which required steps have been completed and which tools
24+
have been executed (with args) for prerequisite enforcement.
1125
1226
Lives on the WorkflowRunner, outside the message history.
1327
Compaction cannot invalidate step completion. See P0-1.
1428
"""
1529

1630
required_steps: list[str]
1731
completed_steps: dict[str, None] = field(default_factory=dict)
32+
executed_tools: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
1833

19-
def record(self, tool_name: str) -> None:
20-
"""Record a tool call as completed."""
34+
def record(self, tool_name: str, args: dict[str, Any] | None = None) -> None:
35+
"""Record a successful tool execution.
36+
37+
Args:
38+
tool_name: The tool that was executed.
39+
args: The arguments the tool was called with. Stored for
40+
arg-matched prerequisite checking.
41+
"""
2142
self.completed_steps[tool_name] = None
43+
self.executed_tools.setdefault(tool_name, []).append(args or {})
2244

2345
def is_satisfied(self) -> bool:
2446
"""True if all required steps have been called."""
@@ -28,6 +50,45 @@ def pending(self) -> list[str]:
2850
"""Return required steps not yet completed, preserving original order."""
2951
return [s for s in self.required_steps if s not in self.completed_steps]
3052

53+
def check_prerequisites(
54+
self,
55+
tool_name: str,
56+
args: dict[str, Any],
57+
prerequisites: list[str | dict[str, str]],
58+
) -> PrerequisiteCheck:
59+
"""Check whether prerequisites are satisfied for a tool call.
60+
61+
Args:
62+
tool_name: The tool about to be called (for error context).
63+
args: The arguments the tool is being called with.
64+
prerequisites: The prerequisite definitions from ToolDef.
65+
66+
Returns:
67+
PrerequisiteCheck with satisfied=True if all prereqs are met,
68+
or satisfied=False with the list of unsatisfied prereq tool names.
69+
"""
70+
missing: list[str] = []
71+
for prereq in prerequisites:
72+
if isinstance(prereq, str):
73+
# Name-only: any prior call to this tool satisfies it
74+
if prereq not in self.executed_tools:
75+
missing.append(prereq)
76+
elif isinstance(prereq, dict):
77+
# Arg-matched: a prior call with the same arg value satisfies it
78+
prereq_tool = prereq["tool"]
79+
match_arg = prereq["match_arg"]
80+
required_value = args.get(match_arg)
81+
if prereq_tool not in self.executed_tools:
82+
missing.append(prereq_tool)
83+
continue
84+
if not any(
85+
call.get(match_arg) == required_value
86+
for call in self.executed_tools[prereq_tool]
87+
):
88+
missing.append(prereq_tool)
89+
90+
return PrerequisiteCheck(satisfied=len(missing) == 0, missing=missing)
91+
3192
def summary_hint(self) -> str:
3293
"""Human-readable hint for injection into compacted summaries."""
3394
if not self.completed_steps:

src/forge/core/workflow.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from collections.abc import Callable
6-
from dataclasses import dataclass
6+
from dataclasses import dataclass, field
77
from typing import Any, Literal, Optional
88

99
from pydantic import BaseModel, ConfigDict, Field, create_model
@@ -145,10 +145,17 @@ class ToolDef:
145145
Downstream projects define tools as ToolDefs. The Workflow holds these
146146
in a dict keyed by name, deriving the spec list (for the LLM) and
147147
callable lookup (for execution) internally.
148+
149+
Prerequisites express conditional dependencies: "if you call this tool,
150+
you must have called tool X first." Entries can be:
151+
- str: name-only ("read_file" — any prior call to read_file satisfies it)
152+
- dict: arg-matched ({"tool": "read_file", "match_arg": "path"} — a prior
153+
call to read_file with the same ``path`` value satisfies it)
148154
"""
149155

150156
spec: ToolSpec
151157
callable: Callable[..., Any]
158+
prerequisites: list[str | dict[str, str]] = field(default_factory=list)
152159

153160
@property
154161
def name(self) -> str:
@@ -214,6 +221,14 @@ def __post_init__(self) -> None:
214221
raise ValueError(
215222
f"Terminal tool '{self.terminal_tool}' cannot also be a required step"
216223
)
224+
for key, tool_def in self.tools.items():
225+
for prereq in tool_def.prerequisites:
226+
prereq_name = prereq if isinstance(prereq, str) else prereq["tool"]
227+
if prereq_name not in tool_names:
228+
raise ValueError(
229+
f"Prerequisite '{prereq_name}' for tool '{key}' "
230+
f"not in tools: {tool_names}"
231+
)
217232

218233
def build_system_prompt(self, **kwargs: str) -> str:
219234
"""Render the system prompt with user-provided values."""

src/forge/errors.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,24 @@ def __init__(
8888
self.pending_steps = pending_steps
8989

9090

91+
class PrerequisiteError(ForgeError):
92+
"""Model repeatedly called a tool without satisfying its prerequisites."""
93+
94+
def __init__(
95+
self,
96+
tool_name: str,
97+
violations: int,
98+
missing_prereqs: list[str],
99+
):
100+
super().__init__(
101+
f"Tool '{tool_name}' called {violations} times "
102+
f"without satisfying prerequisites: {missing_prereqs}"
103+
)
104+
self.tool_name = tool_name
105+
self.violations = violations
106+
self.missing_prereqs = missing_prereqs
107+
108+
91109
class ContextBudgetExceeded(ForgeError):
92110
"""Context exceeded budget even after compaction. Unrecoverable."""
93111

0 commit comments

Comments
 (0)