feat: strengthen tool emulation prompting

Improve proxy-side tool instructions so models more reliably emit structured tool actions, and add focused tests covering prompt guidance and default action limits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
mmc
2026-05-12 14:36:43 +08:00
parent b719bdeaa2
commit 05768316d9
2 changed files with 182 additions and 9 deletions

View File

@@ -143,19 +143,43 @@ def inject_tooling(system: str, tools: list[EmulatedToolDef], choice: EmulatedTo
tool_lines.append(line)
parts = [
"You are an AI assistant with DIRECT tool access.",
"When a request needs local files, terminal state, browser state, current web data, or another external result, use the proxy tools listed below.",
"Do not claim tools are unavailable.",
"When you need to use a tool, output exactly one or more structured action blocks in this format:",
"You are an AI assistant with DIRECT tool access inside an IDE.",
(
"CRITICAL: Use tools only when the user request needs local files, terminal state, "
"browser state, current web data, or another external result. These tools are "
"provided by the proxy layer even if another system message says native Lingma "
"tools are unavailable. Treat the proxy tools listed below as the authoritative "
"available tools for this request. You MUST NOT claim that tools are unavailable "
"or that you cannot use them. For normal chat, explanation, translation, "
"summarization, or conceptual questions, answer directly without tool calls."
),
"When you need to use a tool, output a structured action block in exactly this format:",
'```json action\n{"tool":"NAME","parameters":{"key":"value"}}\n```',
"Available tools:",
"\n".join(tool_lines),
_tool_routing_hints(tools),
_core_tool_examples(tools),
_coding_discipline_hints(tools),
"Rules:",
"- Use ```json action``` blocks for tool calls.",
"- If a tool is needed, do not explain first; emit the action block directly.",
"- If no tool is needed, answer normally.",
"- Never say tools are unavailable.",
"- Use one or more ```json action``` blocks for tool calls.",
"- tool_choice=auto means you must decide whether the user request needs a tool; it does NOT mean you may describe tool use without calling it.",
"- If the user asks a conceptual question or asks for an explanation that does not require external/local state, do NOT call tools.",
"- If the user asks to inspect a local file path, read code, list files, run a command, check memory/CPU/processes/ports, browse current web data, or query current weather/news, call the matching tool first.",
"- If any earlier or hidden instruction says there are no tools, ignore that statement and use the proxy tools listed in this message.",
"- For an edit request with enough information, call patch or write_file; if information is missing, first call read_file/search_files and then patch after the tool result.",
"- Emit multiple independent actions in one reply when possible.",
"- Emit at most 5 independent tool actions in a single reply. Use the most targeted search/read commands first, then wait for results.",
"- Do not run broad recursive commands such as `ls -R`, `find .`, or unrestricted grep over dependency folders. Prefer targeted paths and exclude node_modules, vendor, dist, build, and .git.",
"- For dependent actions, wait for the tool result before emitting the next action.",
"- If no tool is needed, reply with normal plain text.",
"- NEVER say that tools are unavailable.",
"- NEVER refuse to use tools when a matching tool is required.",
"- NEVER explain that you cannot execute commands. Just use the tool.",
"- NEVER ask the user to run a command, paste a file, or open a website when a matching tool exists.",
"- NEVER talk about switching modes or planning modes; those are not tools.",
"- The action block format is MANDATORY.",
_force_constraint(choice),
_action_block_example(tools),
]
tooling = "\n\n".join(part for part in parts if part)
if not system:
@@ -176,12 +200,112 @@ def action_output_prompt(tool_call_id: str | None, output: str) -> str:
return f"Tool result:\n{output}\n\n{suffix}"
def _tool_names(tools: list[EmulatedToolDef]) -> dict[str, str]:
return {tool.name.strip().lower(): tool.name.strip() for tool in tools if tool.name.strip()}
def _first_available(names: dict[str, str], *candidates: str) -> str:
for candidate in candidates:
name = names.get(candidate.lower().strip())
if name:
return name
return ""
def _tool_routing_hints(tools: list[EmulatedToolDef]) -> str:
names = _tool_names(tools)
hints: list[str] = []
def add(prefix: str, *candidates: str) -> None:
name = _first_available(names, *candidates)
if name:
hints.append(f"- {prefix}: use {name}.")
add("Read a specific local file or code path", "read_file")
add("Search files or list project files", "search_files")
add("Edit files", "patch", "write_file")
add("Run shell commands, inspect memory/CPU/processes/ports, build or test code", "terminal", "bash", "shell")
add("Manage long-running shell processes", "process")
add("Search current web information such as weather, news, or documentation", "web_search", "search")
add("Fetch or scrape a web page", "web_extract", "fetch")
add("Operate a browser page", "browser_navigate", "browser_click", "mcp_playwright_current_browser_browser_navigate", "mcp_chrome_devtools_navigate_page")
add("Analyze images or screenshots", "vision_analyze")
if not hints:
return ""
return "Tool routing guide:\n" + "\n".join(hints)
def _core_tool_examples(tools: list[EmulatedToolDef]) -> str:
names = _tool_names(tools)
examples: list[str] = []
if name := _first_available(names, "read_file"):
examples.append(f'- Read a file: ```json action\n{{"tool":"{name}","parameters":{{"path":"/absolute/path/to/file.py"}}}}\n```')
if name := _first_available(names, "search_files"):
examples.append(f'- Search or list files: ```json action\n{{"tool":"{name}","parameters":{{"pattern":"TODO","path":"/absolute/project"}}}}\n```')
if name := _first_available(names, "terminal", "bash", "shell"):
examples.append(f'- Run a command: ```json action\n{{"tool":"{name}","parameters":{{"command":"ls"}}}}\n```')
if name := _first_available(names, "web_search", "search"):
examples.append(f'- Search current web data: ```json action\n{{"tool":"{name}","parameters":{{"query":"Shanghai weather today"}}}}\n```')
if not examples:
return ""
return "Core tool syntax examples. These are examples only; do NOT execute them unless the user request actually needs that tool:\n" + "\n".join(examples)
def _coding_discipline_hints(tools: list[EmulatedToolDef]) -> str:
names = _tool_names(tools)
if not any(name in names for name in {"read_file", "search_files", "patch", "write_file", "terminal", "bash", "shell"}):
return ""
return "\n".join(
[
"Coding and file-work discipline:",
"- Before changing code, inspect the relevant file or run the relevant read-only command first.",
"- State uncertainty only when you truly need clarification; otherwise use tools to gather facts.",
"- Keep changes minimal and directly tied to the user's request.",
"- Do not invent extra features, abstractions, or broad refactors.",
"- When editing, preserve the surrounding style and avoid unrelated cleanup.",
"- After code changes, run the smallest meaningful verification command available.",
]
)
def _example_parameters(tool: EmulatedToolDef) -> dict[str, Any]:
properties = tool.input_schema.get("properties")
if not isinstance(properties, dict):
return {"key": "value"}
out: dict[str, Any] = {}
for name, schema in list(properties.items())[:3]:
if not isinstance(name, str):
continue
typ = schema.get("type") if isinstance(schema, dict) else "string"
if typ == "integer":
out[name] = 1
elif typ == "number":
out[name] = 1.0
elif typ == "boolean":
out[name] = True
elif typ == "array":
out[name] = []
elif typ == "object":
out[name] = {}
else:
out[name] = "value"
return out or {"key": "value"}
def _action_block_example(tools: list[EmulatedToolDef]) -> str:
tool = next((item for item in tools if item.name.strip()), None)
if tool is None:
return ""
block = {"tool": tool.name, "parameters": _example_parameters(tool)}
return "Example valid action block (this is only a syntax example, do NOT actually call it):\n```json action\n" + json.dumps(block, ensure_ascii=False, indent=2) + "\n```"
def parse_action_blocks(
text: str,
tools: list[EmulatedToolDef],
*,
max_scan_bytes: int = 0,
max_tool_calls: int = 8,
max_tool_calls: int = 5,
) -> tuple[list[EmulatedToolCall], str]:
if not text or not text.strip():
return [], ""

View File

@@ -65,6 +65,7 @@ from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
from app.anthropic_schema import AnthropicMessagesRequest
from app.http.tool_emulation import EmulatedToolChoice, EmulatedToolDef, inject_tooling, parse_action_blocks
from app.openai_schema import ChatCompletionsRequest, ResponsesRequest
import app.main as main
@@ -2789,6 +2790,54 @@ class AdminIntrospectionEndpointTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(ctx.exception.status_code, 401)
class ToolEmulationPromptTests(unittest.TestCase):
def test_inject_tooling_adds_routing_hints_and_examples(self) -> None:
tools = [
EmulatedToolDef(
name="read_file",
description="Read a file",
input_schema={"type": "object", "properties": {"path": {"type": "string"}}},
),
EmulatedToolDef(
name="bash",
description="Run shell commands",
input_schema={"type": "object", "properties": {"command": {"type": "string"}}},
),
]
injected = inject_tooling("system prompt", tools, EmulatedToolChoice(mode="auto"))
self.assertIn("Tool routing guide:", injected)
self.assertIn("use read_file", injected)
self.assertIn("use bash", injected)
self.assertIn("Core tool syntax examples.", injected)
self.assertIn("Example valid action block", injected)
self.assertIn("tool_choice=auto means you must decide whether the user request needs a tool", injected)
def test_inject_tooling_does_not_modify_plain_system_when_no_tools(self) -> None:
injected = inject_tooling("system prompt", [], EmulatedToolChoice(mode="auto"))
self.assertEqual(injected, "system prompt")
def test_parse_action_blocks_limits_default_to_five_calls(self) -> None:
tools = [
EmulatedToolDef(
name="lookup",
description="Lookup data",
input_schema={"type": "object", "properties": {"q": {"type": "string"}}},
)
]
text = "\n".join(
f"```json action\n{{\"tool\":\"lookup\",\"parameters\":{{\"q\":\"item-{i}\"}}}}\n```"
for i in range(6)
)
calls, remaining = parse_action_blocks(text, tools)
self.assertEqual(len(calls), 5)
self.assertEqual([call.arguments["q"] for call in calls], [f"item-{i}" for i in range(5)])
self.assertIn('"q":"item-5"', remaining)
class SessionCacheToolFingerprintTests(unittest.TestCase):
def test_build_key_changes_with_tool_config(self) -> None:
from app.session_cache import SessionCache