Skip to content

Commit c4c9f2b

Browse files
Add synthetic respond tool for chat in tool-equipped sessions (#20)
The model calls respond(message="...") instead of producing bare text, eliminating the trust_text_intent tradeoff. Three paths: - WorkflowRunner: respond_tool() factory returns a ready-made ToolDef - Proxy: auto-injected when tools present, stripped on return (client sees finish_reason="stop", never knows respond exists) - Middleware: include "respond" in tool_names, handle in your loop (see examples/foreign_loop.py Part 3) Also updates docs for KV cache quantization and multi-slot support. Closes #15 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4a5add6 commit c4c9f2b

9 files changed

Lines changed: 419 additions & 12 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ python -m forge.proxy --backend llamaserver --gguf path/to/model.gguf --port 808
110110

111111
Then configure your client to use `http://localhost:8081/v1` as the API base URL.
112112

113-
**Note:** The proxy trusts the model's intent when it responds with text instead of calling tools (`trust_text_intent=True`). This eliminates retry latency on conversational turns but means forge won't nudge the model if it *should* have called a tool. In our eval testing, unconditionally trusting intent dropped an 8B model from 100% to as low as 4% on reasoning-heavy workflows — which is why WorkflowRunner and middleware default to `trust_text_intent=False` (full guardrail protection). The proxy accepts this tradeoff for zero-code integration; the client's own agentic loop handles re-prompting. See [ADR-013](docs/decisions/013-text-response-intent.md).
113+
**Note:** The proxy automatically injects a synthetic `respond` tool when tools are present in the request. The model calls `respond(message="...")` instead of producing bare text, keeping it in tool-calling mode where forge's full guardrail stack applies. The `respond` call is stripped from the outbound response — the client sees a normal text response (`finish_reason: "stop"`) and never knows the tool exists. This eliminates the `trust_text_intent` tradeoff described in [ADR-013](docs/decisions/013-text-response-intent.md) — no retries wasted on conversational turns, no accuracy loss on tool-calling turns.
114114

115115
## Backends
116116

@@ -182,13 +182,15 @@ src/forge/
182182
prompts/
183183
templates.py # Tool prompt builders (prompt-injected path)
184184
nudges.py # Retry and step-enforcement nudge templates
185+
tools/
186+
respond.py # Synthetic respond tool (respond_tool(), respond_spec())
185187
proxy/
186188
proxy.py # ProxyServer — programmatic start/stop API
187189
server.py # Raw asyncio HTTP server, SSE streaming
188190
handler.py # Request handler — bridge between HTTP and run_inference
189191
convert.py # OpenAI messages ↔ forge Messages conversion
190192
tests/
191-
unit/ # 562 deterministic tests — no LLM backend required
193+
unit/ # 655 deterministic tests — no LLM backend required
192194
eval/ # Eval harness — model qualification against real backends
193195
```
194196

docs/ARCHITECTURE.md

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,6 +1234,10 @@ forge/
12341234
│ │ ├── handler.py # Request handler — bridge between HTTP and run_inference
12351235
│ │ └── convert.py # OpenAI messages ↔ forge Messages conversion
12361236
│ │
1237+
│ ├── tools/
1238+
│ │ ├── __init__.py # Built-in tool exports
1239+
│ │ └── respond.py # Synthetic respond tool (respond_tool(), respond_spec())
1240+
│ │
12371241
│ ├── server.py # ServerManager, BudgetMode, setup_backend()
12381242
│ └── errors.py # ForgeError hierarchy
12391243
@@ -1540,9 +1544,13 @@ class ServerManager:
15401544
"""
15411545
...
15421546

1543-
async def start(self, model, gguf_path, mode="native", extra_flags=None, ctx_override=None) -> None:
1547+
async def start(self, model, gguf_path, mode="native", extra_flags=None,
1548+
ctx_override=None, cache_type_k=None, cache_type_v=None,
1549+
n_slots=None) -> None:
15441550
"""Start a llama-server/llamafile process. No-op for Ollama.
1545-
Reuses existing process if same model+mode+ctx+flags."""
1551+
Reuses existing process if same model+mode+ctx+flags+cache+slots.
1552+
cache_type_k/v: KV cache quantization (e.g. "q8_0") — halves cache VRAM.
1553+
n_slots: number of concurrent slots (--parallel N)."""
15461554
...
15471555

15481556
async def stop(self) -> None:
@@ -1554,7 +1562,9 @@ class ServerManager:
15541562
...
15551563

15561564
async def start_with_budget(self, model, gguf_path, mode="native",
1557-
budget_mode=BudgetMode.BACKEND, ...) -> int:
1565+
budget_mode=BudgetMode.BACKEND, ...,
1566+
cache_type_k=None, cache_type_v=None,
1567+
n_slots=None) -> int:
15581568
"""Start server + resolve budget in one call. Returns budget in tokens."""
15591569
...
15601570
```
@@ -1587,3 +1597,41 @@ runner = WorkflowRunner(client=client, context_manager=ctx)
15871597
# ... run workflows ...
15881598
await server.stop()
15891599
```
1600+
1601+
---
1602+
1603+
## Synthetic Respond Tool
1604+
1605+
When tools are present but the user sends a conversational message, small models must choose between calling a tool and responding with text. They frequently choose wrong — producing text when they should call tools, or vice versa. The `trust_text_intent` flag (ADR-013) addressed this for the proxy by trusting the model's finish reason, but this dropped 8B models from 100% to as low as 4% on reasoning-heavy workflows.
1606+
1607+
The respond tool eliminates this ambiguity. The model calls `respond(message="...")` instead of producing bare text. From forge's perspective, every response is a valid tool call — no retries wasted on conversational turns, no accuracy loss on tool-calling turns.
1608+
1609+
### Three paths
1610+
1611+
**WorkflowRunner:** Use `respond_tool()` from `forge.tools` to get a ready-made `ToolDef`. Set it as the terminal tool. The callable returns the message string.
1612+
1613+
```python
1614+
from forge import respond_tool
1615+
1616+
tools = {
1617+
"search": search_tool,
1618+
"respond": respond_tool(),
1619+
}
1620+
workflow = Workflow(..., tools=tools, terminal_tool="respond")
1621+
```
1622+
1623+
**Proxy:** The respond tool is injected automatically when tools are present in the request. The client never sees it — respond calls are converted to plain text responses (`finish_reason: "stop"`) before returning. This makes `trust_text_intent` irrelevant for the proxy path.
1624+
1625+
**Middleware:** Include `"respond"` in `tool_names` when creating a `ResponseValidator` or `Guardrails` instance. The respond call passes validation like any other tool call. See `examples/foreign_loop.py` Part 3 for a complete example.
1626+
1627+
### Why this works for small models
1628+
1629+
Small models struggle with open-ended decisions ("should I use tools or chat?") but are good at structured choices ("which tool should I call?"). The respond tool converts an open-ended decision into a structured one. The model stays in tool-calling grammar/template at all times, which is where it performs best.
1630+
1631+
### KV Cache Quantization
1632+
1633+
`ServerManager.start()` and `setup_backend()` accept `cache_type_k` and `cache_type_v` parameters for KV cache quantization. Using Q8 (`cache_type_k="q8_0"`, `cache_type_v="q8_0"`) roughly halves KV cache VRAM, effectively doubling usable context for the same GPU memory. Measured: 36,864 → 68,608 tokens (1.86x) on Ministral 8B Q4 with no eval regression.
1634+
1635+
### Multi-Slot Support
1636+
1637+
`LlamafileClient` accepts a `slot_id` parameter to route requests to specific llama-server slots. `ServerManager.start()` accepts `n_slots` to start the server with `--parallel N`. This enables multi-agent architectures where each agent targets a dedicated KV cache slot.

docs/WORKFLOW.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Visual guide to the forge agentic tool-calling loop.
1919
- `src/forge/context/strategies.py` - TieredCompact (3-phase compaction)
2020
- `src/forge/clients/base.py` - LLMClient protocol
2121
- `src/forge/server.py` - ServerManager, BudgetMode, setup_backend()
22+
- `src/forge/tools/respond.py` - Synthetic respond tool (respond_tool(), respond_spec())
2223
- `src/forge/prompts/nudges.py` - Retry, unknown-tool, and step nudges
2324
- `src/forge/prompts/templates.py` - Prompt-injected tool prompt, extract/rescue
2425

examples/foreign_loop.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
result = guardrails.check(response) # before execution
88
done = guardrails.record(["tool"]) # after execution
99
10-
This file has two parts:
10+
This file has three parts:
1111
Part 1 -- The simple API (Guardrails facade)
1212
Part 2 -- The granular API (individual middleware components)
13+
Part 3 -- Using the respond tool for chat in tool-equipped sessions
1314
1415
No LLM backend required -- uses scripted responses to show each guardrail.
1516
"""
@@ -112,7 +113,7 @@ def handle_response_granular(response):
112113
# Demo
113114
# =====================================================================
114115

115-
EXAMPLES = [
116+
EXAMPLES_WORKFLOW = [
116117
("Model returns plain text (no tool call)",
117118
TextResponse(content="I think the answer is 42.")),
118119
("Model jumps to terminal tool, skipping required steps",
@@ -125,9 +126,74 @@ def handle_response_granular(response):
125126
[ToolCall(tool="answer", args={"text": "The answer is 42."})]),
126127
]
127128

129+
# =====================================================================
130+
# Part 3: Respond tool
131+
#
132+
# In tool-equipped sessions, the model must choose between calling a
133+
# tool and responding with text. Small models frequently get this
134+
# wrong -- they produce text when they should call tools, or vice versa.
135+
#
136+
# The respond tool eliminates this ambiguity: the model calls
137+
# respond(message="...") instead of producing bare text. From forge's
138+
# perspective, every response is a valid tool call -- no retries wasted
139+
# on conversational turns, no accuracy loss on tool-calling turns.
140+
#
141+
# For WorkflowRunner consumers: use respond_tool() from forge.tools.
142+
# For proxy consumers: respond is injected automatically.
143+
# For middleware consumers: include "respond" in tool_names and handle
144+
# the respond tool call in your execution logic.
145+
# =====================================================================
146+
147+
from forge.tools import RESPOND_TOOL_NAME, respond_spec
148+
149+
# Middleware with respond -- include it in tool_names
150+
respond_guardrails = Guardrails(
151+
tool_names=["search", "lookup", "answer", RESPOND_TOOL_NAME],
152+
required_steps=["search", "lookup"],
153+
terminal_tool="answer",
154+
)
155+
156+
157+
def handle_response_with_respond(response):
158+
"""Process a response that may include a respond() call."""
159+
160+
result = respond_guardrails.check(response)
161+
162+
if result.action == "fatal":
163+
return f"FATAL: {result.reason}"
164+
165+
if result.action in ("retry", "step_blocked"):
166+
return f"{result.action}: {result.nudge.content[:80]}..."
167+
168+
tool_calls = result.tool_calls
169+
170+
# Check if the model called respond -- extract the message
171+
for tc in tool_calls:
172+
if tc.tool == RESPOND_TOOL_NAME:
173+
message = tc.args.get("message", "")
174+
return f"MODEL SAYS: {message}"
175+
176+
# Normal tool execution
177+
executed = [tc.tool for tc in tool_calls]
178+
done = respond_guardrails.record(executed)
179+
return f"executed {executed}" + (" -- DONE" if done else "")
180+
181+
182+
EXAMPLES_RESPOND = [
183+
("Model produces bare text (retried -- respond not used)",
184+
TextResponse(content="Hello, how can I help?")),
185+
("Model correctly uses respond for chat",
186+
[ToolCall(tool="respond", args={"message": "Hello! How can I help you?"})]),
187+
("Model calls a real tool",
188+
[ToolCall(tool="search", args={"query": "meaning of life"})]),
189+
("Model calls respond after completing work",
190+
[ToolCall(tool="respond", args={"message": "I found the answer: 42."})]),
191+
]
192+
193+
128194
if __name__ == "__main__":
129195
print("=== Part 1: Simple API (Guardrails) ===")
130-
for i, (desc, response) in enumerate(EXAMPLES, 1):
196+
for i, (desc, response) in enumerate(EXAMPLES_WORKFLOW, 1):
131197
print(f"\n{i}. {desc}")
132198
print(f" {handle_response_simple(response)}")
133199

@@ -137,6 +203,11 @@ def handle_response_granular(response):
137203
errors._consecutive_retries = 0
138204

139205
print("\n\n=== Part 2: Granular API (individual components) ===")
140-
for i, (desc, response) in enumerate(EXAMPLES, 1):
206+
for i, (desc, response) in enumerate(EXAMPLES_WORKFLOW, 1):
141207
print(f"\n{i}. {desc}")
142208
print(f" {handle_response_granular(response)}")
209+
210+
print("\n\n=== Part 3: Respond tool ===")
211+
for i, (desc, response) in enumerate(EXAMPLES_RESPOND, 1):
212+
print(f"\n{i}. {desc}")
213+
print(f" {handle_response_with_respond(response)}")

src/forge/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
detect_hardware,
2727
)
2828
from forge.server import BudgetMode, ServerManager, setup_backend
29+
from forge.tools import RESPOND_TOOL_NAME, respond_spec, respond_tool
2930
from forge.prompts import build_tool_prompt, extract_tool_call, rescue_tool_call, retry_nudge, step_nudge
3031
from forge.guardrails import (
3132
CheckResult,
@@ -100,6 +101,10 @@
100101
"BudgetMode",
101102
"ServerManager",
102103
"setup_backend",
104+
# Built-in tools
105+
"RESPOND_TOOL_NAME",
106+
"respond_spec",
107+
"respond_tool",
103108
# Guardrails
104109
"CheckResult",
105110
"Guardrails",

src/forge/proxy/handler.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
text_response_to_openai,
2020
text_to_sse_events,
2121
)
22+
from forge.tools.respond import RESPOND_TOOL_NAME, respond_spec
2223

2324
logger = logging.getLogger("forge.proxy")
2425

@@ -79,6 +80,14 @@ async def handle_chat_completions(
7980
# Convert inbound
8081
messages = openai_to_messages(openai_messages)
8182
tool_specs = _extract_tool_specs(request_tools)
83+
84+
# Inject respond tool when tools are present. The model calls
85+
# respond(message="...") instead of producing bare text, keeping it
86+
# in tool-calling mode where guardrails apply. The respond call is
87+
# stripped from the outbound response — the client never sees it.
88+
if tool_specs and not any(s.name == RESPOND_TOOL_NAME for s in tool_specs):
89+
tool_specs.append(respond_spec())
90+
8291
tool_names = _extract_tool_names(tool_specs)
8392

8493
# No tools → plain chat completion, no guardrails needed.
@@ -133,7 +142,28 @@ async def handle_chat_completions(
133142

134143
tool_calls = result.response
135144

136-
# Convert outbound
145+
# Strip respond() calls — convert to plain text for the client.
146+
# If the model called respond(message="..."), the client sees a
147+
# normal text response (finish_reason="stop"), not a tool call.
148+
respond_calls = [tc for tc in tool_calls if tc.tool == RESPOND_TOOL_NAME]
149+
other_calls = [tc for tc in tool_calls if tc.tool != RESPOND_TOOL_NAME]
150+
151+
if respond_calls and not other_calls:
152+
# Pure respond — convert to text
153+
text = respond_calls[0].args.get("message", "")
154+
logger.info("Stripping respond() call, returning as text")
155+
if is_stream:
156+
return text_to_sse_events(text, model=model_name)
157+
return text_response_to_openai(text, model=model_name)
158+
159+
if other_calls:
160+
# Real tool calls (possibly mixed with respond) — return the
161+
# real tool calls only, drop respond.
162+
if is_stream:
163+
return tool_calls_to_sse_events(other_calls, model=model_name)
164+
return tool_calls_to_openai(other_calls, model=model_name)
165+
166+
# Shouldn't happen, but handle empty tool_calls gracefully
137167
if is_stream:
138-
return tool_calls_to_sse_events(tool_calls, model=model_name)
139-
return tool_calls_to_openai(tool_calls, model=model_name)
168+
return text_to_sse_events("", model=model_name)
169+
return text_response_to_openai("", model=model_name)

src/forge/tools/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Built-in tools provided by forge."""
2+
3+
from forge.tools.respond import (
4+
RESPOND_TOOL_NAME,
5+
respond_spec,
6+
respond_tool,
7+
)
8+
9+
__all__ = [
10+
"RESPOND_TOOL_NAME",
11+
"respond_spec",
12+
"respond_tool",
13+
]

src/forge/tools/respond.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Synthetic respond tool — structured alternative to bare text responses.
2+
3+
The model calls respond(message="...") instead of producing bare text.
4+
This keeps the model in tool-calling mode where forge's full guardrail
5+
stack applies, eliminating the trust_text_intent tradeoff.
6+
7+
Usage:
8+
9+
WorkflowRunner consumers:
10+
tools["respond"] = respond_tool()
11+
workflow = Workflow(..., tools=tools, terminal_tool="respond")
12+
13+
Proxy:
14+
Injected automatically. respond() calls are converted to plain
15+
text responses before returning to the client.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from pydantic import BaseModel, Field
21+
22+
from forge.core.workflow import ToolDef, ToolSpec
23+
24+
25+
RESPOND_TOOL_NAME = "respond"
26+
27+
RESPOND_DESCRIPTION = (
28+
"Respond to the user with a message. Use this when the user is chatting, "
29+
"asking a question, when you need to ask a clarifying question before "
30+
"proceeding, or when no other tool action is needed. Also use this "
31+
"after completing the user's request to report the result."
32+
)
33+
34+
35+
class RespondParams(BaseModel):
36+
message: str = Field(description="The message to send to the user.")
37+
38+
39+
def respond_spec() -> ToolSpec:
40+
"""Return the ToolSpec for the respond tool (what the LLM sees)."""
41+
return ToolSpec(
42+
name=RESPOND_TOOL_NAME,
43+
description=RESPOND_DESCRIPTION,
44+
parameters=RespondParams,
45+
)
46+
47+
48+
def respond_tool() -> ToolDef:
49+
"""Return a complete ToolDef for the respond tool.
50+
51+
The callable simply returns the message string. Use as a terminal
52+
tool in WorkflowRunner workflows.
53+
"""
54+
55+
def _respond(message: str) -> str:
56+
return message
57+
58+
return ToolDef(
59+
spec=respond_spec(),
60+
callable=_respond,
61+
)

0 commit comments

Comments
 (0)