mirror of
https://github.com/cline/cline.git
synced 2026-09-06 20:41:02 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dec7167118 | |||
| 4d36f93b18 | |||
| bbba1235d2 | |||
| d4a8e3d5a0 | |||
| 99eeace344 | |||
| 56aacb33ea |
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
- name: Run Tests
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os != 'windows-latest' }}
|
||||
run: bun run test
|
||||
run: bun run test && bun -F cline-xml-tool-calling-plugin test
|
||||
|
||||
- name: Run SDK Tests (Windows)
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'windows-latest' }}
|
||||
|
||||
@@ -615,6 +615,16 @@
|
||||
"@cline/core",
|
||||
],
|
||||
},
|
||||
"sdk/examples/plugins/xml-tool-calling": {
|
||||
"name": "cline-xml-tool-calling-plugin",
|
||||
"version": "0.1.0",
|
||||
"peerDependencies": {
|
||||
"@cline/core": "*",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@cline/core",
|
||||
],
|
||||
},
|
||||
"sdk/packages/agents": {
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.59",
|
||||
@@ -2962,6 +2972,8 @@
|
||||
|
||||
"cline-agent-squad-plugin": ["cline-agent-squad-plugin@workspace:sdk/examples/plugins/agents-squad"],
|
||||
|
||||
"cline-xml-tool-calling-plugin": ["cline-xml-tool-calling-plugin@workspace:sdk/examples/plugins/xml-tool-calling"],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
|
||||
@@ -24,6 +24,7 @@ What a plugin can do:
|
||||
| [openrouter-provider.ts](./openrouter-provider.ts) | Custom model provider via `registerProvider` | Registers an OpenAI-compatible model provider (pointed at OpenRouter) plus its model catalog so the agent can run inference against it. Swap the base URL, API key env var, and models to add any OpenAI-compatible endpoint Cline does not bundle. Requires `OPENROUTER_API_KEY`. |
|
||||
| [typescript-lsp/](./typescript-lsp/) | `goto_definition` tool powered by the TypeScript Language Service | Adds `goto_definition(file, line)` for TypeScript/JavaScript projects. It loads the target project’s own TypeScript version, finds identifiers on a line, and resolves definitions through imports, re-exports, aliases, and other language-service semantics. |
|
||||
| [agents-squad/](./agents-squad/) | Multi-agent team — spin up subagents with their own models and personalities | Adds tools for starting, messaging, polling, and coordinating background subagents. It includes bundled agent presets, skill discovery/loading, and a shared handoff store for passing notes between subagents in the same conversation. |
|
||||
| [xml-tool-calling/](./xml-tool-calling/) | Legacy-style XML tool calling via rules + `beforeModel`/`afterModel` | Replaces native function calling with the XML tag format the legacy Cline extension used — for local/weak models that fumble native tool schemas. A rule adds the XML instructions, `beforeModel` strips tool schemas and injects per-turn tool docs into the provider-bound messages, and `afterModel` parses XML tool uses from assistant text back into native tool calls. |
|
||||
|
||||
The runtime-hook variant of compaction lives in [../hooks/custom-compaction-hook.example.ts](../hooks/custom-compaction-hook.example.ts).
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# XML Tool Calling
|
||||
|
||||
Drives Cline's tools through XML tags in plain assistant text — the format the
|
||||
legacy Cline extension used before native function calling — instead of
|
||||
provider-native tool schemas. Local and weaker models that fumble native tool
|
||||
calling tend to handle this format far better.
|
||||
|
||||
## How it works
|
||||
|
||||
The plugin is a pure translation shim at the model boundary. Internal session
|
||||
state stays in native form; only the provider-bound request is translated, so
|
||||
approvals, tool executors, completion tools, events, and persistence all work
|
||||
exactly as they do with native tool calling.
|
||||
|
||||
A registered rule adds the static `TOOL USE` instructions (the XML format and
|
||||
usage guidelines) to the system prompt. Then, per model call:
|
||||
|
||||
1. **`beforeModel`** strips the native tool schemas from the request
|
||||
(`tools: []`), injects a `TOOL DOCUMENTATION` block into the
|
||||
provider-bound first user message with per-tool XML docs generated from
|
||||
the live tool registry (including tools contributed by other plugins),
|
||||
and rewrites prior turns in the provider-bound history — native tool
|
||||
calls become XML text, tool results become plain user messages. The docs
|
||||
can't live in the rule: rules are resolved before the effective tool set
|
||||
(mode filtering, tool policies, other plugins' tools) is knowable, and
|
||||
the set can change between runs.
|
||||
2. The model replies with tool uses as XML tags:
|
||||
|
||||
```
|
||||
I'll read that file.
|
||||
<read_files>
|
||||
<paths>["src/main.ts"]</paths>
|
||||
</read_files>
|
||||
```
|
||||
|
||||
3. **`afterModel`** parses the XML out of the assistant text and replaces the
|
||||
message with one carrying native `tool-call` parts, which the runtime then
|
||||
executes through the ordinary tool pipeline.
|
||||
|
||||
Parameter values are coerced to the tool's JSON Schema types: numbers,
|
||||
booleans, and JSON for `array`/`object` params. Values that fail coercion pass
|
||||
through as raw strings so the tool's own input validation produces an error
|
||||
the model can react to.
|
||||
|
||||
The parser is a port of the legacy extension's `parseAssistantMessageV2`,
|
||||
generalized from a fixed tool list to schema-derived tool and parameter names.
|
||||
It keeps the legacy recovery trick for parameter values that contain their own
|
||||
closing tag (e.g. file content containing `</content>`), generalized to every
|
||||
parameter.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cline plugin install ./sdk/examples/plugins/xml-tool-calling
|
||||
cline -i "..."
|
||||
```
|
||||
|
||||
Or from an SDK host, pass it via `extensions`:
|
||||
|
||||
```typescript
|
||||
import xmlToolCalling from "./sdk/examples/plugins/xml-tool-calling/index.ts";
|
||||
|
||||
await host.start({
|
||||
config: {
|
||||
// a local model that struggles with native tool calling
|
||||
providerId: "ollama",
|
||||
modelId: "qwen3:8b",
|
||||
extensions: [xmlToolCalling],
|
||||
// ...
|
||||
},
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
Requires an SDK build where `afterModel` hook results support `message`
|
||||
replacement.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Streaming**: text streams as raw `assistant-text-delta` events before
|
||||
`afterModel` runs, so live UIs show the XML while it streams. The final
|
||||
persisted message is clean (tool calls become native parts).
|
||||
- **Unclosed tool uses** (max-tokens truncation, malformed XML) are kept as
|
||||
raw text and not executed.
|
||||
- **Plain-text replies end the run**, same as a native model turn without tool
|
||||
calls. Pair with a completion policy that requires a completion tool if you
|
||||
want the runtime to nudge the model instead.
|
||||
- Tool results are rendered as text; image outputs are JSON-stringified rather
|
||||
than passed as image blocks.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
bun test
|
||||
```
|
||||
|
||||
Covers the parser, prompt generation, schema coercion, history rewriting, and
|
||||
an end-to-end run through `AgentRuntime` with a scripted model.
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* XML tool calling for models without reliable native function calling.
|
||||
*
|
||||
* The legacy Cline extension drove tools through XML tags in plain assistant
|
||||
* text — a format that weak/local models handle far better than native tool
|
||||
* schemas. This plugin recreates that mode on the SDK runtime as a pure
|
||||
* translation shim at the model boundary:
|
||||
*
|
||||
* - A registered rule adds the static XML "TOOL USE" instructions to the
|
||||
* system prompt.
|
||||
* - `beforeModel` strips the native tool schemas from the provider request,
|
||||
* injects per-turn "TOOL DOCUMENTATION" (generated from the live tool
|
||||
* registry) into the provider-bound first user message, and rewrites prior
|
||||
* tool calls/results in history into the XML wire format.
|
||||
* - `afterModel` parses XML tool uses out of the assistant's text and
|
||||
* replaces the message with one carrying native `tool-call` parts.
|
||||
*
|
||||
* Everything downstream — approval hooks, tool executors, completion tools,
|
||||
* events, persistence — sees ordinary native tool calls. Internal session
|
||||
* state stays in native form; only the provider-bound request is translated.
|
||||
*/
|
||||
|
||||
import type { AgentPlugin } from "@cline/core";
|
||||
import {
|
||||
buildXmlToolDocs,
|
||||
coerceToolInput,
|
||||
formatToolResultText,
|
||||
parseAssistantXml,
|
||||
serializeToolCallXml,
|
||||
toXmlToolSpecs,
|
||||
XML_TOOL_CALLING_RULE,
|
||||
type XmlToolSpec,
|
||||
} from "./xml-format.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime types, derived structurally from the plugin contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RuntimeHooks = NonNullable<AgentPlugin["hooks"]>;
|
||||
type BeforeModelContext = Parameters<
|
||||
NonNullable<RuntimeHooks["beforeModel"]>
|
||||
>[0];
|
||||
type AfterModelContext = Parameters<NonNullable<RuntimeHooks["afterModel"]>>[0];
|
||||
type RuntimeMessage = BeforeModelContext["request"]["messages"][number];
|
||||
type RuntimeMessagePart = RuntimeMessage["content"][number];
|
||||
|
||||
function isInsideMarkdownFence(text: string): boolean {
|
||||
let activeFence: { marker: string; length: number } | undefined;
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
||||
if (!match) continue;
|
||||
const run = match[1];
|
||||
if (!run) continue;
|
||||
if (!activeFence) {
|
||||
activeFence = { marker: run[0] ?? "", length: run.length };
|
||||
} else if (
|
||||
run[0] === activeFence.marker &&
|
||||
run.length >= activeFence.length &&
|
||||
line.slice(match[0].length).trim().length === 0
|
||||
) {
|
||||
activeFence = undefined;
|
||||
}
|
||||
}
|
||||
return activeFence !== undefined;
|
||||
}
|
||||
|
||||
function isExecutableXmlCall(text: string, raw: string): boolean {
|
||||
const callStart = text.indexOf(raw);
|
||||
if (callStart === -1 || text.slice(callStart + raw.length).trim()) {
|
||||
return false;
|
||||
}
|
||||
const lineStart = text.lastIndexOf("\n", callStart - 1) + 1;
|
||||
return (
|
||||
/^ {0,3}$/.test(text.slice(lineStart, callStart)) &&
|
||||
!isInsideMarkdownFence(text.slice(0, callStart))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-agent state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tool specs captured in `beforeModel`, keyed by agent id. `afterModel` does
|
||||
* not receive the tool list, and `beforeModel` always runs first in the same
|
||||
* turn, so the entry is guaranteed fresh when the parse step reads it.
|
||||
*/
|
||||
const toolSpecsByAgent = new Map<string, Map<string, XmlToolSpec>>();
|
||||
|
||||
let toolCallCounter = 0;
|
||||
function nextToolCallId(): string {
|
||||
toolCallCounter += 1;
|
||||
return `xml_call_${toolCallCounter}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider-bound history rewriting (native parts -> XML wire format)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rewriteHistoryForXml(
|
||||
messages: readonly RuntimeMessage[],
|
||||
): RuntimeMessage[] {
|
||||
return messages.map((message) => {
|
||||
const hasToolPart = message.content.some(
|
||||
(part) => part.type === "tool-call" || part.type === "tool-result",
|
||||
);
|
||||
if (!hasToolPart) {
|
||||
return message;
|
||||
}
|
||||
const content: RuntimeMessagePart[] = message.content.map((part) => {
|
||||
if (part.type === "tool-call") {
|
||||
return {
|
||||
type: "text",
|
||||
text: serializeToolCallXml(part.toolName, part.input),
|
||||
};
|
||||
}
|
||||
if (part.type === "tool-result") {
|
||||
return {
|
||||
type: "text",
|
||||
text: formatToolResultText(part.toolName, part.output, part.isError),
|
||||
};
|
||||
}
|
||||
return part;
|
||||
});
|
||||
// Tool-result messages carry the "tool" role, which providers reject
|
||||
// when no tool schemas are in the request — they become user messages,
|
||||
// matching how the legacy extension fed results back.
|
||||
const role = message.role === "tool" ? "user" : message.role;
|
||||
return { ...message, role, content };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assistant text -> native tool-call parts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function convertAssistantXml(
|
||||
message: AfterModelContext["assistantMessage"],
|
||||
specs: ReadonlyMap<string, XmlToolSpec>,
|
||||
): AfterModelContext["assistantMessage"] | undefined {
|
||||
const parsedParts = message.content.map((part) =>
|
||||
part.type === "text" ? parseAssistantXml(part.text, specs) : undefined,
|
||||
);
|
||||
const candidates = parsedParts.flatMap((blocks, partIndex) =>
|
||||
(blocks ?? [])
|
||||
.filter((block) => block.type === "tool_use")
|
||||
.map((block) => ({ block, partIndex })),
|
||||
);
|
||||
const candidate = candidates[0];
|
||||
const candidatePart = candidate && message.content[candidate.partIndex];
|
||||
if (
|
||||
candidates.length !== 1 ||
|
||||
!candidate ||
|
||||
candidatePart?.type !== "text" ||
|
||||
candidate.block.partial ||
|
||||
!specs.has(candidate.block.name) ||
|
||||
message.content
|
||||
.slice(candidate.partIndex + 1)
|
||||
.some((part) =>
|
||||
part.type === "text" ? part.text.trim().length > 0 : true,
|
||||
) ||
|
||||
!isExecutableXmlCall(candidatePart.text, candidate.block.raw)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const content: RuntimeMessagePart[] = [];
|
||||
let converted = false;
|
||||
for (const [partIndex, part] of message.content.entries()) {
|
||||
if (part.type !== "text") {
|
||||
content.push(part);
|
||||
continue;
|
||||
}
|
||||
for (const block of parsedParts[partIndex] ?? []) {
|
||||
if (block.type === "text") {
|
||||
content.push({ type: "text", text: block.text });
|
||||
continue;
|
||||
}
|
||||
const spec = specs.get(block.name);
|
||||
if (block.partial || !spec) {
|
||||
// Unclosed tool use (truncation or malformed XML): keep the raw
|
||||
// source as text rather than executing a half-parsed call.
|
||||
content.push({ type: "text", text: block.raw });
|
||||
continue;
|
||||
}
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: nextToolCallId(),
|
||||
toolName: block.name,
|
||||
input: coerceToolInput(block.params, spec),
|
||||
});
|
||||
converted = true;
|
||||
}
|
||||
}
|
||||
if (!converted) {
|
||||
return undefined;
|
||||
}
|
||||
return { ...message, content };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-turn tool documentation, injected into the provider-bound messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Prepend the TOOL DOCUMENTATION block to the first user message of the
|
||||
* provider-bound copy. The docs cannot live in the registered rule because
|
||||
* rules are resolved before the effective tool set (mode filtering, tool
|
||||
* policies, other plugins' tools) is knowable, and the set can change
|
||||
* between runs — `request.tools` in `beforeModel` is the only accurate
|
||||
* per-turn source.
|
||||
*/
|
||||
function injectToolDocs(
|
||||
messages: readonly RuntimeMessage[],
|
||||
docs: string,
|
||||
): RuntimeMessage[] {
|
||||
const docsPart: RuntimeMessagePart = {
|
||||
type: "text",
|
||||
text: `${docs}\n\n====\n`,
|
||||
};
|
||||
const firstUserIndex = messages.findIndex(
|
||||
(message) => message.role === "user",
|
||||
);
|
||||
return messages.map((message, index) =>
|
||||
index === firstUserIndex
|
||||
? { ...message, content: [docsPart, ...message.content] }
|
||||
: message,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const plugin: AgentPlugin = {
|
||||
name: "xml-tool-calling",
|
||||
manifest: {
|
||||
capabilities: ["hooks", "rules"],
|
||||
},
|
||||
setup(api) {
|
||||
api.registerRule({
|
||||
id: "xml-tool-calling:instructions",
|
||||
source: "xml-tool-calling",
|
||||
content: XML_TOOL_CALLING_RULE,
|
||||
});
|
||||
},
|
||||
hooks: {
|
||||
beforeModel({ snapshot, request }: BeforeModelContext) {
|
||||
if (request.tools.length === 0) {
|
||||
toolSpecsByAgent.delete(snapshot.agentId);
|
||||
return undefined;
|
||||
}
|
||||
const specs = toXmlToolSpecs(request.tools);
|
||||
toolSpecsByAgent.set(snapshot.agentId, specs);
|
||||
const messages = injectToolDocs(
|
||||
rewriteHistoryForXml(request.messages),
|
||||
buildXmlToolDocs(specs),
|
||||
);
|
||||
return { tools: [], messages };
|
||||
},
|
||||
afterModel({ snapshot, assistantMessage }: AfterModelContext) {
|
||||
const specs = toolSpecsByAgent.get(snapshot.agentId);
|
||||
if (!specs || specs.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const converted = convertAssistantXml(assistantMessage, specs);
|
||||
return converted ? { message: converted } : undefined;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export { convertAssistantXml, plugin, rewriteHistoryForXml };
|
||||
export default plugin;
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "cline-xml-tool-calling-plugin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "XML tool calling for models without reliable native function calling (local/weak models)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test",
|
||||
"clean": "rm -rf node_modules dist"
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"cline": {
|
||||
"plugins": [
|
||||
{
|
||||
"paths": [
|
||||
"./index.ts"
|
||||
],
|
||||
"capabilities": [
|
||||
"hooks",
|
||||
"rules"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cline/core": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cline/core": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { AgentRuntime } from "@cline/agents";
|
||||
import type {
|
||||
AgentModel,
|
||||
AgentModelEvent,
|
||||
AgentModelRequest,
|
||||
AgentRuntimeStateSnapshot,
|
||||
AgentTool,
|
||||
} from "@cline/shared";
|
||||
import plugin, { rewriteHistoryForXml } from "./index.ts";
|
||||
|
||||
function makeSnapshot(): AgentRuntimeStateSnapshot {
|
||||
return {
|
||||
agentId: "agent-test",
|
||||
status: "running",
|
||||
iteration: 1,
|
||||
messages: [],
|
||||
pendingToolCalls: [],
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ECHO_TOOL: AgentTool<{ text: string }, { echoed: string }> = {
|
||||
name: "echo",
|
||||
description: "Echo input text back.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" } },
|
||||
required: ["text"],
|
||||
},
|
||||
async execute(input) {
|
||||
return { echoed: input.text };
|
||||
},
|
||||
};
|
||||
|
||||
class ScriptedModel implements AgentModel {
|
||||
public readonly requests: AgentModelRequest[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly steps: Array<
|
||||
(request: AgentModelRequest) => AgentModelEvent[]
|
||||
>,
|
||||
) {}
|
||||
|
||||
async stream(
|
||||
request: AgentModelRequest,
|
||||
): Promise<AsyncIterable<AgentModelEvent>> {
|
||||
this.requests.push(request);
|
||||
const step = this.steps.shift();
|
||||
if (!step) {
|
||||
throw new Error("No scripted model step available");
|
||||
}
|
||||
const events = step(request);
|
||||
return (async function* () {
|
||||
yield* events;
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
describe("beforeModel", () => {
|
||||
it("strips native tools, appends XML docs, and rewrites tool history", async () => {
|
||||
const result = await plugin.hooks?.beforeModel?.({
|
||||
snapshot: makeSnapshot(),
|
||||
request: {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "echo hi" }],
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Echoing." },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_1",
|
||||
toolName: "echo",
|
||||
input: { text: "hi" },
|
||||
},
|
||||
],
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "echo",
|
||||
output: { echoed: "hi" },
|
||||
},
|
||||
],
|
||||
createdAt: 3,
|
||||
},
|
||||
],
|
||||
tools: [ECHO_TOOL],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.tools).toEqual([]);
|
||||
|
||||
const messages = result?.messages;
|
||||
expect(messages).toHaveLength(3);
|
||||
// Tool docs are injected at the top of the first user message.
|
||||
const firstUser = messages?.[0];
|
||||
expect(firstUser?.role).toBe("user");
|
||||
const docsPart = firstUser?.content[0];
|
||||
if (docsPart?.type !== "text") throw new Error("expected text part");
|
||||
expect(docsPart.text).toContain("TOOL DOCUMENTATION");
|
||||
expect(docsPart.text).toContain("## echo");
|
||||
expect(firstUser?.content[1]).toEqual({ type: "text", text: "echo hi" });
|
||||
|
||||
const assistant = messages?.[1];
|
||||
expect(assistant?.content).toEqual([
|
||||
{ type: "text", text: "Echoing." },
|
||||
{ type: "text", text: "<echo>\n<text>hi</text>\n</echo>" },
|
||||
]);
|
||||
const toolTurn = messages?.[2];
|
||||
expect(toolTurn?.role).toBe("user");
|
||||
expect(toolTurn?.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: `[echo] Result:\n${JSON.stringify({ echoed: "hi" }, null, 2)}`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does nothing when the request has no tools", async () => {
|
||||
const result = await plugin.hooks?.beforeModel?.({
|
||||
snapshot: makeSnapshot(),
|
||||
request: {
|
||||
messages: [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("afterModel", () => {
|
||||
it("converts XML tool uses into native tool-call parts", async () => {
|
||||
const snapshot = makeSnapshot();
|
||||
await plugin.hooks?.beforeModel?.({
|
||||
snapshot,
|
||||
request: { messages: [], tools: [ECHO_TOOL] },
|
||||
});
|
||||
|
||||
const result = await plugin.hooks?.afterModel?.({
|
||||
snapshot,
|
||||
assistantMessage: {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "I will echo now.\n<echo>\n<text>hi there</text>\n</echo>",
|
||||
},
|
||||
],
|
||||
createdAt: 1,
|
||||
},
|
||||
finishReason: "stop",
|
||||
});
|
||||
|
||||
expect(result?.message?.content).toEqual([
|
||||
{ type: "text", text: "I will echo now." },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: expect.stringMatching(/^xml_call_\d+$/),
|
||||
toolName: "echo",
|
||||
input: { text: "hi there" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves plain-text replies untouched", async () => {
|
||||
const snapshot = makeSnapshot();
|
||||
await plugin.hooks?.beforeModel?.({
|
||||
snapshot,
|
||||
request: { messages: [], tools: [ECHO_TOOL] },
|
||||
});
|
||||
|
||||
const result = await plugin.hooks?.afterModel?.({
|
||||
snapshot,
|
||||
assistantMessage: {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "All done!" }],
|
||||
createdAt: 1,
|
||||
},
|
||||
finishReason: "stop",
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps unclosed tool uses as raw text instead of executing them", async () => {
|
||||
const snapshot = makeSnapshot();
|
||||
await plugin.hooks?.beforeModel?.({
|
||||
snapshot,
|
||||
request: { messages: [], tools: [ECHO_TOOL] },
|
||||
});
|
||||
|
||||
const result = await plugin.hooks?.afterModel?.({
|
||||
snapshot,
|
||||
assistantMessage: {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "<echo>\n<text>truncat" }],
|
||||
createdAt: 1,
|
||||
},
|
||||
finishReason: "max-tokens",
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not execute quoted, fenced, trailing, or multiple XML calls", async () => {
|
||||
const snapshot = makeSnapshot();
|
||||
await plugin.hooks?.beforeModel?.({
|
||||
snapshot,
|
||||
request: { messages: [], tools: [ECHO_TOOL] },
|
||||
});
|
||||
const calls = [
|
||||
"Example: <echo>\n<text>quoted</text>\n</echo>",
|
||||
"```xml\n<echo>\n<text>fenced</text>\n</echo>\n```",
|
||||
"```xml\n<echo>\n<text>unclosed fence</text>\n</echo>",
|
||||
"<echo>\n<text>not terminal</text>\n</echo>\nMore text.",
|
||||
"<echo>\n<text>one</text>\n</echo>\n<echo>\n<text>two</text>\n</echo>",
|
||||
];
|
||||
|
||||
for (const text of calls) {
|
||||
const result = await plugin.hooks?.afterModel?.({
|
||||
snapshot,
|
||||
assistantMessage: {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
createdAt: 1,
|
||||
},
|
||||
finishReason: "stop",
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup", () => {
|
||||
it("registers the static XML instructions as a rule", async () => {
|
||||
const rules: Array<{ id: string; content: unknown }> = [];
|
||||
const api = {
|
||||
registerTool: () => {},
|
||||
registerCommand: () => {},
|
||||
registerRule: (rule: { id: string; content: unknown }) => {
|
||||
rules.push(rule);
|
||||
},
|
||||
registerMessageBuilder: () => {},
|
||||
registerProvider: () => {},
|
||||
registerAutomationEventType: () => {},
|
||||
};
|
||||
await plugin.setup?.(api as never, {});
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0]?.id).toBe("xml-tool-calling:instructions");
|
||||
expect(String(rules[0]?.content)).toContain("TOOL USE");
|
||||
expect(String(rules[0]?.content)).toContain("<tool_name>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteHistoryForXml", () => {
|
||||
it("leaves messages without tool parts untouched", () => {
|
||||
const message = {
|
||||
id: "u1",
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "hello" }],
|
||||
createdAt: 1,
|
||||
};
|
||||
expect(rewriteHistoryForXml([message])).toEqual([message]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("end to end with AgentRuntime", () => {
|
||||
it("drives a full XML tool-calling turn through the runtime", async () => {
|
||||
const model = new ScriptedModel([
|
||||
(request) => {
|
||||
expect(request.tools).toEqual([]);
|
||||
// Tool docs ride in the provider-bound first user message. (The
|
||||
// static rule is merged into the system prompt by the core
|
||||
// orchestrator, which this runtime-level test bypasses.)
|
||||
const firstUser = request.messages.find(
|
||||
(message) => message.role === "user",
|
||||
);
|
||||
expect(JSON.stringify(firstUser?.content)).toContain(
|
||||
"TOOL DOCUMENTATION",
|
||||
);
|
||||
expect(JSON.stringify(firstUser?.content)).toContain("## echo");
|
||||
return [
|
||||
{
|
||||
type: "text-delta",
|
||||
text: "Echoing.\n<echo>\n<text>hello world</text>\n</echo>",
|
||||
},
|
||||
{ type: "finish", reason: "stop" },
|
||||
];
|
||||
},
|
||||
(request) => {
|
||||
expect(request.tools).toEqual([]);
|
||||
// The assistant's tool call went back out as XML text...
|
||||
const assistant = request.messages.find(
|
||||
(message) => message.role === "assistant",
|
||||
);
|
||||
expect(assistant?.content.every((part) => part.type === "text")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(JSON.stringify(assistant?.content)).toContain("<echo>");
|
||||
// ...and the tool result came back as a plain user message.
|
||||
const last = request.messages.at(-1);
|
||||
expect(last?.role).toBe("user");
|
||||
expect(JSON.stringify(last?.content)).toContain("[echo] Result:");
|
||||
expect(JSON.stringify(last?.content)).toContain("hello world");
|
||||
return [
|
||||
{ type: "text-delta", text: "done" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
];
|
||||
},
|
||||
]);
|
||||
|
||||
const runtime = new AgentRuntime({
|
||||
model,
|
||||
systemPrompt: "You are a test agent.",
|
||||
tools: [ECHO_TOOL],
|
||||
hooks: plugin.hooks,
|
||||
});
|
||||
|
||||
const result = await runtime.run("Please echo 'hello world'.");
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.outputText).toBe("done");
|
||||
expect(model.requests).toHaveLength(2);
|
||||
|
||||
// Internal state stays native: the stored assistant message carries a
|
||||
// real tool-call part, and the tool message a real tool-result part.
|
||||
const assistant = result.messages.find(
|
||||
(message) =>
|
||||
message.role === "assistant" &&
|
||||
message.content.some((part) => part.type === "tool-call"),
|
||||
);
|
||||
expect(assistant).toBeDefined();
|
||||
const toolMessage = result.messages.find(
|
||||
(message) => message.role === "tool",
|
||||
);
|
||||
expect(
|
||||
toolMessage?.content.some(
|
||||
(part) =>
|
||||
part.type === "tool-result" &&
|
||||
JSON.stringify(part.output).includes("hello world"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": [
|
||||
"index.ts",
|
||||
"xml-format.ts",
|
||||
"xml-format.test.ts",
|
||||
"plugin.test.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
buildXmlToolDocs,
|
||||
coerceToolInput,
|
||||
formatToolResultText,
|
||||
parseAssistantXml,
|
||||
serializeToolCallXml,
|
||||
toXmlToolSpecs,
|
||||
XML_TOOL_CALLING_RULE,
|
||||
type XmlToolDefinition,
|
||||
} from "./xml-format.ts";
|
||||
|
||||
const TOOLS: XmlToolDefinition[] = [
|
||||
{
|
||||
name: "read_file",
|
||||
description: "Read a file from disk.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Relative file path." },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "write_to_file",
|
||||
description: "Create or overwrite a file.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
content: { type: "string" },
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "run_commands",
|
||||
description: "Run shell commands.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
commands: { type: "array", items: { type: "string" } },
|
||||
timeout_secs: {
|
||||
anyOf: [{ type: "integer" }, { type: "null" }],
|
||||
},
|
||||
background: { type: "boolean" },
|
||||
},
|
||||
required: ["commands"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "attempt_completion",
|
||||
description: "Present the final result.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { result: { type: "string" } },
|
||||
required: ["result"],
|
||||
},
|
||||
lifecycle: { completesRun: true },
|
||||
},
|
||||
];
|
||||
|
||||
const specs = toXmlToolSpecs(TOOLS);
|
||||
|
||||
describe("parseAssistantXml", () => {
|
||||
it("parses a single tool use with surrounding text", () => {
|
||||
const blocks = parseAssistantXml(
|
||||
"Let me read that file.\n<read_file>\n<path>src/main.ts</path>\n</read_file>",
|
||||
specs,
|
||||
);
|
||||
expect(blocks).toEqual([
|
||||
{ type: "text", text: "Let me read that file." },
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "read_file",
|
||||
params: { path: "src/main.ts" },
|
||||
partial: false,
|
||||
raw: "<read_file>\n<path>src/main.ts</path>\n</read_file>",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses multiple parameters", () => {
|
||||
const blocks = parseAssistantXml(
|
||||
"<write_to_file>\n<path>a.txt</path>\n<content>hello world</content>\n</write_to_file>",
|
||||
specs,
|
||||
);
|
||||
expect(blocks).toHaveLength(1);
|
||||
const tool = blocks[0];
|
||||
if (tool?.type !== "tool_use") throw new Error("expected tool_use");
|
||||
expect(tool.params).toEqual({ path: "a.txt", content: "hello world" });
|
||||
expect(tool.partial).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves meaningful parameter whitespace", () => {
|
||||
const blocks = parseAssistantXml(
|
||||
"<write_to_file>\n<path>a.txt</path>\n<content>\n\n indented\n\n</content>\n</write_to_file>",
|
||||
specs,
|
||||
);
|
||||
const tool = blocks[0];
|
||||
if (tool?.type !== "tool_use") throw new Error("expected tool_use");
|
||||
expect(tool.params.content).toBe("\n indented\n");
|
||||
});
|
||||
|
||||
it("recovers content values containing their own closing tag", () => {
|
||||
const content =
|
||||
"<note>first</note>\nliteral </content> inside\n<note>second</note>";
|
||||
const blocks = parseAssistantXml(
|
||||
`<write_to_file>\n<path>notes.xml</path>\n<content>\n${content}\n</content>\n</write_to_file>`,
|
||||
specs,
|
||||
);
|
||||
const tool = blocks[0];
|
||||
if (tool?.type !== "tool_use") throw new Error("expected tool_use");
|
||||
expect(tool.params.path).toBe("notes.xml");
|
||||
expect(tool.params.content).toBe(content);
|
||||
});
|
||||
|
||||
it("marks an unclosed tool use as partial and keeps its raw source", () => {
|
||||
const text = "Working on it.\n<read_file>\n<path>src/main.ts";
|
||||
const blocks = parseAssistantXml(text, specs);
|
||||
expect(blocks).toEqual([
|
||||
{ type: "text", text: "Working on it." },
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "read_file",
|
||||
params: { path: "src/main.ts" },
|
||||
partial: true,
|
||||
raw: "<read_file>\n<path>src/main.ts",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats unknown tags as plain text", () => {
|
||||
const blocks = parseAssistantXml(
|
||||
"<thinking>hmm</thinking> just text <unknown_tool><path>x</path></unknown_tool>",
|
||||
specs,
|
||||
);
|
||||
expect(blocks).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "<thinking>hmm</thinking> just text <unknown_tool><path>x</path></unknown_tool>",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses multiple tool uses in one message", () => {
|
||||
const blocks = parseAssistantXml(
|
||||
"<read_file><path>a.ts</path></read_file>then<read_file><path>b.ts</path></read_file>",
|
||||
specs,
|
||||
);
|
||||
expect(
|
||||
blocks.map((block) =>
|
||||
block.type === "tool_use" ? block.params.path : block.text,
|
||||
),
|
||||
).toEqual(["a.ts", "then", "b.ts"]);
|
||||
});
|
||||
|
||||
it("returns a single text block when no tools are present", () => {
|
||||
expect(parseAssistantXml("All done!", specs)).toEqual([
|
||||
{ type: "text", text: "All done!" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coerceToolInput", () => {
|
||||
const runCommandsSpec = specs.get("run_commands");
|
||||
if (!runCommandsSpec) throw new Error("missing spec");
|
||||
|
||||
it("coerces schema-typed params from strings", () => {
|
||||
expect(
|
||||
coerceToolInput(
|
||||
{
|
||||
commands: '["ls", "pwd"]',
|
||||
timeout_secs: "30",
|
||||
background: "true",
|
||||
},
|
||||
runCommandsSpec,
|
||||
),
|
||||
).toEqual({ commands: ["ls", "pwd"], timeout_secs: 30, background: true });
|
||||
});
|
||||
|
||||
it("passes through values that fail coercion for the tool to validate", () => {
|
||||
expect(
|
||||
coerceToolInput(
|
||||
{ commands: "not json", timeout_secs: "soon", background: "maybe" },
|
||||
runCommandsSpec,
|
||||
),
|
||||
).toEqual({
|
||||
commands: "not json",
|
||||
timeout_secs: "soon",
|
||||
background: "maybe",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps string params verbatim", () => {
|
||||
const readSpec = specs.get("read_file");
|
||||
if (!readSpec) throw new Error("missing spec");
|
||||
expect(coerceToolInput({ path: "42" }, readSpec)).toEqual({ path: "42" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt content", () => {
|
||||
const docs = buildXmlToolDocs(specs);
|
||||
|
||||
it("keeps the static rule free of tool-specific content", () => {
|
||||
expect(XML_TOOL_CALLING_RULE).toContain("TOOL USE");
|
||||
expect(XML_TOOL_CALLING_RULE).toContain("<tool_name>");
|
||||
for (const tool of TOOLS) {
|
||||
expect(XML_TOOL_CALLING_RULE).not.toContain(tool.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("documents every tool with usage skeletons", () => {
|
||||
expect(docs).toContain("TOOL DOCUMENTATION");
|
||||
for (const tool of TOOLS) {
|
||||
expect(docs).toContain(`## ${tool.name}`);
|
||||
expect(docs).toContain(`<${tool.name}>`);
|
||||
expect(docs).toContain(`</${tool.name}>`);
|
||||
}
|
||||
expect(docs).toContain("- path: (required, text)");
|
||||
expect(docs).toContain("- commands: (required, JSON array)");
|
||||
expect(docs).toContain("- background: (optional, true or false)");
|
||||
});
|
||||
|
||||
it("points at completion tools when present", () => {
|
||||
expect(docs).toContain("`attempt_completion`");
|
||||
});
|
||||
|
||||
it("falls back to plain-text completion guidance without completion tools", () => {
|
||||
const withoutCompletion = buildXmlToolDocs(
|
||||
toXmlToolSpecs(TOOLS.filter((tool) => !tool.lifecycle?.completesRun)),
|
||||
);
|
||||
expect(withoutCompletion).toContain("reply in plain text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialization round trip", () => {
|
||||
it("serializes tool calls back into parseable XML", () => {
|
||||
const xml = serializeToolCallXml("run_commands", {
|
||||
commands: ["ls", "pwd"],
|
||||
timeout_secs: 30,
|
||||
background: false,
|
||||
});
|
||||
const blocks = parseAssistantXml(xml, specs);
|
||||
expect(blocks).toHaveLength(1);
|
||||
const tool = blocks[0];
|
||||
if (tool?.type !== "tool_use") throw new Error("expected tool_use");
|
||||
const runCommandsSpec = specs.get("run_commands");
|
||||
if (!runCommandsSpec) throw new Error("missing spec");
|
||||
expect(coerceToolInput(tool.params, runCommandsSpec)).toEqual({
|
||||
commands: ["ls", "pwd"],
|
||||
timeout_secs: 30,
|
||||
background: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats string and structured tool results", () => {
|
||||
expect(formatToolResultText("read_file", "file body", undefined)).toBe(
|
||||
"[read_file] Result:\nfile body",
|
||||
);
|
||||
expect(formatToolResultText("run_commands", { code: 1 }, true)).toBe(
|
||||
`[run_commands] Error:\n${JSON.stringify({ code: 1 }, null, 2)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* Pure XML tool-calling primitives: prompt generation, assistant-message
|
||||
* parsing, and provider-bound serialization.
|
||||
*
|
||||
* This module is dependency-free on purpose — the types below are structural
|
||||
* mirrors of the `@cline/core` agent contracts, so the plugin can pass its
|
||||
* runtime values straight in while the parser stays unit-testable in
|
||||
* isolation.
|
||||
*
|
||||
* The parser is a port of the legacy Cline extension's
|
||||
* `parseAssistantMessageV2` (apps/vscode/src/core/assistant-message/),
|
||||
* generalized from a fixed tool list to schema-derived tool and parameter
|
||||
* names.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool specs (derived from JSON Schema tool definitions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Structural mirror of `AgentToolDefinition`. */
|
||||
export interface XmlToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
lifecycle?: {
|
||||
completesRun?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface XmlToolParamSpec {
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface XmlToolSpec {
|
||||
name: string;
|
||||
description: string;
|
||||
params: XmlToolParamSpec[];
|
||||
completesRun: boolean;
|
||||
}
|
||||
|
||||
function concreteSchemaTypeOf(propSchema: unknown): string | undefined {
|
||||
if (!propSchema || typeof propSchema !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = propSchema as Record<string, unknown>;
|
||||
const type = record.type;
|
||||
if (typeof type === "string" && type !== "null") {
|
||||
return type;
|
||||
}
|
||||
if (Array.isArray(type)) {
|
||||
const first = type.find(
|
||||
(entry) => typeof entry === "string" && entry !== "null",
|
||||
);
|
||||
if (typeof first === "string") {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
for (const keyword of ["anyOf", "oneOf"] as const) {
|
||||
const alternatives = record[keyword];
|
||||
if (!Array.isArray(alternatives)) continue;
|
||||
for (const alternative of alternatives) {
|
||||
const nestedType = concreteSchemaTypeOf(alternative);
|
||||
if (nestedType) return nestedType;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function schemaTypeOf(propSchema: unknown): string {
|
||||
return concreteSchemaTypeOf(propSchema) ?? "string";
|
||||
}
|
||||
|
||||
function schemaDescriptionOf(propSchema: unknown): string | undefined {
|
||||
if (!propSchema || typeof propSchema !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = propSchema as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
if (typeof record.description === "string" && record.description.trim()) {
|
||||
parts.push(record.description.trim());
|
||||
}
|
||||
if (Array.isArray(record.enum)) {
|
||||
parts.push(
|
||||
`One of: ${record.enum.map((v) => JSON.stringify(v)).join(", ")}.`,
|
||||
);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" ") : undefined;
|
||||
}
|
||||
|
||||
export function toXmlToolSpec(tool: XmlToolDefinition): XmlToolSpec {
|
||||
const schema = tool.inputSchema ?? {};
|
||||
const properties =
|
||||
schema.properties && typeof schema.properties === "object"
|
||||
? (schema.properties as Record<string, unknown>)
|
||||
: {};
|
||||
const required = new Set(
|
||||
Array.isArray(schema.required)
|
||||
? schema.required.filter(
|
||||
(entry): entry is string => typeof entry === "string",
|
||||
)
|
||||
: [],
|
||||
);
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
params: Object.entries(properties).map(([name, propSchema]) => ({
|
||||
name,
|
||||
type: schemaTypeOf(propSchema),
|
||||
description: schemaDescriptionOf(propSchema),
|
||||
required: required.has(name),
|
||||
})),
|
||||
completesRun: tool.lifecycle?.completesRun === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function toXmlToolSpecs(
|
||||
tools: readonly XmlToolDefinition[],
|
||||
): Map<string, XmlToolSpec> {
|
||||
const specs = new Map<string, XmlToolSpec>();
|
||||
for (const tool of tools) {
|
||||
specs.set(tool.name, toXmlToolSpec(tool));
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System prompt section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function paramPlaceholder(param: XmlToolParamSpec): string {
|
||||
switch (param.type) {
|
||||
case "number":
|
||||
case "integer":
|
||||
return "42";
|
||||
case "boolean":
|
||||
return "true or false";
|
||||
case "array":
|
||||
return '["item1", "item2"] (a JSON array)';
|
||||
case "object":
|
||||
return '{"key": "value"} (a JSON object)';
|
||||
default:
|
||||
return `${param.name.replaceAll("_", " ")} here`;
|
||||
}
|
||||
}
|
||||
|
||||
function paramTypeLabel(param: XmlToolParamSpec): string {
|
||||
switch (param.type) {
|
||||
case "number":
|
||||
case "integer":
|
||||
return "number";
|
||||
case "boolean":
|
||||
return "true or false";
|
||||
case "array":
|
||||
return "JSON array";
|
||||
case "object":
|
||||
return "JSON object";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
function buildToolDoc(spec: XmlToolSpec): string {
|
||||
const lines: string[] = [
|
||||
`## ${spec.name}`,
|
||||
`Description: ${spec.description}`,
|
||||
];
|
||||
if (spec.params.length === 0) {
|
||||
lines.push("Parameters: none");
|
||||
lines.push("Usage:", `<${spec.name}>`, `</${spec.name}>`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
lines.push("Parameters:");
|
||||
for (const param of spec.params) {
|
||||
const requirement = param.required ? "required" : "optional";
|
||||
const description = param.description ? ` ${param.description}` : "";
|
||||
lines.push(
|
||||
`- ${param.name}: (${requirement}, ${paramTypeLabel(param)})${description}`,
|
||||
);
|
||||
}
|
||||
lines.push("Usage:", `<${spec.name}>`);
|
||||
for (const param of spec.params) {
|
||||
lines.push(`<${param.name}>${paramPlaceholder(param)}</${param.name}>`);
|
||||
}
|
||||
lines.push(`</${spec.name}>`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Static XML tool-use instructions, registered as a system prompt rule via
|
||||
* `api.registerRule`. Adapted from the legacy Cline extension's XML tool-use
|
||||
* prompt. The per-tool documentation is dynamic (the tool set varies per
|
||||
* turn) and travels separately — see `buildXmlToolDocs`.
|
||||
*/
|
||||
export const XML_TOOL_CALLING_RULE = `====
|
||||
|
||||
TOOL USE
|
||||
|
||||
You do NOT have access to native function calling. Instead, you use tools by writing XML-style tags directly in your plain-text reply. Tool uses are parsed from your reply and executed by the user's system; you receive each result in the next user message. The available tools are documented under "TOOL DOCUMENTATION" in the first user message.
|
||||
|
||||
# Tool Use Formatting
|
||||
|
||||
A tool use is formatted with the tool name as the outer XML tag and each parameter inside its own tag:
|
||||
|
||||
<tool_name>
|
||||
<parameter1_name>value 1</parameter1_name>
|
||||
<parameter2_name>value 2</parameter2_name>
|
||||
</tool_name>
|
||||
|
||||
Always use the actual tool name as the XML tag name, exactly as documented. Do not wrap tool calls in code fences or JSON. Parameter values are plain text between the tags; for parameters typed as JSON array or JSON object, write valid JSON between the tags.
|
||||
|
||||
# Tool Use Guidelines
|
||||
|
||||
1. Use exactly ONE tool per message, at the end of your reply.
|
||||
2. Wait for the tool result in the next message before continuing. NEVER assume a tool succeeded.
|
||||
3. If a tool result reports an error, address it before retrying.
|
||||
4. Only use tools listed in TOOL DOCUMENTATION.`;
|
||||
|
||||
/**
|
||||
* The dynamic "TOOL DOCUMENTATION" block generated from the live tool
|
||||
* registry each turn and injected into the provider-bound first user
|
||||
* message. Kept out of the rule because rules are resolved before the
|
||||
* effective tool set (mode filtering, policies, other plugins' tools) is
|
||||
* knowable, and the set can change between runs.
|
||||
*/
|
||||
export function buildXmlToolDocs(
|
||||
specs: ReadonlyMap<string, XmlToolSpec>,
|
||||
): string {
|
||||
const completionTools = [...specs.values()]
|
||||
.filter((spec) => spec.completesRun)
|
||||
.map((spec) => spec.name);
|
||||
const docs = [...specs.values()].map(buildToolDoc).join("\n\n");
|
||||
const completionGuidance =
|
||||
completionTools.length > 0
|
||||
? `When the task is fully complete, use ${completionTools
|
||||
.map((name) => `\`${name}\``)
|
||||
.join(" or ")} to finish.`
|
||||
: "When the task is fully complete, reply in plain text without any tool tags.";
|
||||
return `TOOL DOCUMENTATION
|
||||
|
||||
These are the tools currently available to you. Invoke them with XML tags as described in the TOOL USE section of your instructions.
|
||||
|
||||
${docs}
|
||||
|
||||
${completionGuidance}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing assistant text into tool uses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ParsedTextBlock {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ParsedToolUseBlock {
|
||||
type: "tool_use";
|
||||
name: string;
|
||||
params: Record<string, string>;
|
||||
/** True when the input ended before the tool's closing tag. */
|
||||
partial: boolean;
|
||||
/** Original source slice for this tool use (open tag through close tag). */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export type ParsedAssistantBlock = ParsedTextBlock | ParsedToolUseBlock;
|
||||
|
||||
interface OpenToolState {
|
||||
name: string;
|
||||
spec: XmlToolSpec;
|
||||
params: Record<string, string>;
|
||||
/** Absolute index of `<` of the opening tag. */
|
||||
openTagStart: number;
|
||||
/** Absolute index just past the opening tag. */
|
||||
contentStart: number;
|
||||
/** Param name -> absolute index just past its consumed closing tag. */
|
||||
paramCloseEnds: Map<string, number>;
|
||||
}
|
||||
|
||||
function removeStructuralNewlines(value: string): string {
|
||||
const start = value.startsWith("\r\n") ? 2 : value.startsWith("\n") ? 1 : 0;
|
||||
let end = value.length;
|
||||
if (end > start) {
|
||||
end -= value.endsWith("\r\n") ? 2 : value.endsWith("\n") ? 1 : 0;
|
||||
}
|
||||
return value.slice(start, Math.max(start, end));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover parameter values whose text contains their own closing tag (the
|
||||
* classic case: file content containing `</content>`). Sequential parsing
|
||||
* consumes the first closing tag; when another closing occurrence exists
|
||||
* later in the tool body, re-extract the value spanning from the first
|
||||
* opening tag to the last closing tag — the legacy parser's `write_to_file`
|
||||
* special case, generalized to every captured parameter.
|
||||
*/
|
||||
function recoverTruncatedParams(
|
||||
text: string,
|
||||
tool: OpenToolState,
|
||||
contentEnd: number,
|
||||
): void {
|
||||
const contentSlice = text.slice(tool.contentStart, contentEnd);
|
||||
for (const [paramName, consumedEnd] of tool.paramCloseEnds) {
|
||||
const closeTag = `</${paramName}>`;
|
||||
const extraClose = text.indexOf(closeTag, consumedEnd);
|
||||
if (extraClose === -1 || extraClose >= contentEnd) {
|
||||
continue;
|
||||
}
|
||||
const openTag = `<${paramName}>`;
|
||||
const openIndex = contentSlice.indexOf(openTag);
|
||||
const lastClose = contentSlice.lastIndexOf(closeTag);
|
||||
if (openIndex === -1 || lastClose <= openIndex) {
|
||||
continue;
|
||||
}
|
||||
tool.params[paramName] = removeStructuralNewlines(
|
||||
contentSlice.slice(openIndex + openTag.length, lastClose),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAssistantXml(
|
||||
text: string,
|
||||
specs: ReadonlyMap<string, XmlToolSpec>,
|
||||
): ParsedAssistantBlock[] {
|
||||
const blocks: ParsedAssistantBlock[] = [];
|
||||
const toolOpenTags = new Map<string, XmlToolSpec>();
|
||||
for (const spec of specs.values()) {
|
||||
toolOpenTags.set(`<${spec.name}>`, spec);
|
||||
}
|
||||
|
||||
let textStart = 0;
|
||||
let tool: OpenToolState | undefined;
|
||||
let paramName: string | undefined;
|
||||
let paramValueStart = 0;
|
||||
|
||||
const len = text.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
// Inside a parameter: only its closing tag matters.
|
||||
if (tool && paramName) {
|
||||
const closeTag = `</${paramName}>`;
|
||||
if (
|
||||
i >= closeTag.length - 1 &&
|
||||
text.startsWith(closeTag, i - closeTag.length + 1)
|
||||
) {
|
||||
tool.params[paramName] = removeStructuralNewlines(
|
||||
text.slice(paramValueStart, i - closeTag.length + 1),
|
||||
);
|
||||
tool.paramCloseEnds.set(paramName, i + 1);
|
||||
paramName = undefined;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Inside a tool body: look for a parameter opening tag or the tool close.
|
||||
if (tool && !paramName) {
|
||||
let startedParam = false;
|
||||
for (const param of tool.spec.params) {
|
||||
const openTag = `<${param.name}>`;
|
||||
if (
|
||||
i >= openTag.length - 1 &&
|
||||
text.startsWith(openTag, i - openTag.length + 1)
|
||||
) {
|
||||
paramName = param.name;
|
||||
paramValueStart = i + 1;
|
||||
startedParam = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (startedParam) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolCloseTag = `</${tool.name}>`;
|
||||
if (
|
||||
i >= toolCloseTag.length - 1 &&
|
||||
text.startsWith(toolCloseTag, i - toolCloseTag.length + 1)
|
||||
) {
|
||||
const contentEnd = i - toolCloseTag.length + 1;
|
||||
recoverTruncatedParams(text, tool, contentEnd);
|
||||
blocks.push({
|
||||
type: "tool_use",
|
||||
name: tool.name,
|
||||
params: tool.params,
|
||||
partial: false,
|
||||
raw: text.slice(tool.openTagStart, i + 1),
|
||||
});
|
||||
tool = undefined;
|
||||
textStart = i + 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// In plain text: look for a tool opening tag.
|
||||
for (const [openTag, spec] of toolOpenTags) {
|
||||
if (
|
||||
i >= openTag.length - 1 &&
|
||||
text.startsWith(openTag, i - openTag.length + 1)
|
||||
) {
|
||||
const tagStart = i - openTag.length + 1;
|
||||
const leadingText = text.slice(textStart, tagStart).trim();
|
||||
if (leadingText.length > 0) {
|
||||
blocks.push({ type: "text", text: leadingText });
|
||||
}
|
||||
tool = {
|
||||
name: spec.name,
|
||||
spec,
|
||||
params: {},
|
||||
openTagStart: tagStart,
|
||||
contentStart: i + 1,
|
||||
paramCloseEnds: new Map(),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize whatever is still open at end of input.
|
||||
if (tool && paramName) {
|
||||
tool.params[paramName] = text.slice(paramValueStart).trim();
|
||||
}
|
||||
if (tool) {
|
||||
blocks.push({
|
||||
type: "tool_use",
|
||||
name: tool.name,
|
||||
params: tool.params,
|
||||
partial: true,
|
||||
raw: text.slice(tool.openTagStart),
|
||||
});
|
||||
} else {
|
||||
const trailingText = text.slice(textStart).trim();
|
||||
if (trailingText.length > 0) {
|
||||
blocks.push({ type: "text", text: trailingText });
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coercing parsed string params into schema-typed tool input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Coerce flat string parameter values into the types declared by the tool's
|
||||
* schema. Values that fail coercion are passed through as raw strings so the
|
||||
* tool's own input validation produces the error the model gets to react to.
|
||||
*/
|
||||
export function coerceToolInput(
|
||||
params: Record<string, string>,
|
||||
spec: XmlToolSpec,
|
||||
): Record<string, unknown> {
|
||||
const types = new Map(spec.params.map((param) => [param.name, param.type]));
|
||||
const input: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(params)) {
|
||||
switch (types.get(key)) {
|
||||
case "number":
|
||||
case "integer": {
|
||||
const value = Number(raw);
|
||||
input[key] = Number.isNaN(value) ? raw : value;
|
||||
break;
|
||||
}
|
||||
case "boolean":
|
||||
input[key] = raw === "true" ? true : raw === "false" ? false : raw;
|
||||
break;
|
||||
case "array":
|
||||
case "object":
|
||||
try {
|
||||
input[key] = JSON.parse(raw);
|
||||
} catch {
|
||||
input[key] = raw;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
input[key] = raw;
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serializing native tool parts back into XML/plain text for the provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatParamValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/** Render a native tool call as the XML the model was instructed to write. */
|
||||
export function serializeToolCallXml(toolName: string, input: unknown): string {
|
||||
const record =
|
||||
input && typeof input === "object" && !Array.isArray(input)
|
||||
? (input as Record<string, unknown>)
|
||||
: {};
|
||||
const params = Object.entries(record)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => `<${key}>${formatParamValue(value)}</${key}>`);
|
||||
return [`<${toolName}>`, ...params, `</${toolName}>`].join("\n");
|
||||
}
|
||||
|
||||
/** Render a native tool result as the plain-text user message the model reads. */
|
||||
export function formatToolResultText(
|
||||
toolName: string,
|
||||
output: unknown,
|
||||
isError: boolean | undefined,
|
||||
): string {
|
||||
const body =
|
||||
typeof output === "string" ? output : JSON.stringify(output, null, 2);
|
||||
const label = isError ? "Error" : "Result";
|
||||
return `[${toolName}] ${label}:\n${body ?? ""}`;
|
||||
}
|
||||
@@ -1284,6 +1284,75 @@ describe("AgentRuntime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces the assistant message from afterModel and executes injected tool calls", async () => {
|
||||
const model = new ScriptedModel([
|
||||
(request) => {
|
||||
expect(request.tools[0]?.lifecycle).toEqual({ completesRun: false });
|
||||
return [
|
||||
{ type: "text-delta", text: "<echo><text>hi</text></echo>" },
|
||||
{ type: "usage", usage: { inputTokens: 7, outputTokens: 3 } },
|
||||
{ type: "finish", reason: "stop" },
|
||||
];
|
||||
},
|
||||
(request) => {
|
||||
const toolMessage = request.messages.at(-1) as AgentMessage;
|
||||
expect(toolMessage.role).toBe("tool");
|
||||
return [
|
||||
{ type: "text-delta", text: "done" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
];
|
||||
},
|
||||
]);
|
||||
const runtime = new AgentRuntime({
|
||||
model,
|
||||
tools: [{ ...createEchoTool(), lifecycle: { completesRun: false } }],
|
||||
hooks: {
|
||||
afterModel: ({ assistantMessage }) => {
|
||||
const text = assistantMessage.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
if (!text.includes("<echo>")) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
message: {
|
||||
...assistantMessage,
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_xml_1",
|
||||
toolName: "echo",
|
||||
input: { text: "hi" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runtime.run("Start");
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.outputText).toBe("done");
|
||||
const assistantWithToolCall = result.messages.find(
|
||||
(message) =>
|
||||
message.role === "assistant" &&
|
||||
message.content.some((part) => part.type === "tool-call"),
|
||||
);
|
||||
expect(assistantWithToolCall).toBeDefined();
|
||||
expect(assistantWithToolCall?.metrics).toMatchObject({
|
||||
inputTokens: 7,
|
||||
outputTokens: 3,
|
||||
});
|
||||
const toolMessages = result.messages.filter(
|
||||
(message) => message.role === "tool",
|
||||
);
|
||||
expect(toolMessages).toHaveLength(1);
|
||||
expect(JSON.stringify(toolMessages[0]?.content)).toContain("hi");
|
||||
});
|
||||
|
||||
it("stamps runtime identity metadata onto model requests", async () => {
|
||||
const model = new ScriptedModel([
|
||||
(request) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createGateway, type GatewayProviderSettings } from "@cline/llms";
|
||||
import type {
|
||||
AgentAfterModelResult,
|
||||
AgentAfterToolResult,
|
||||
AgentBeforeModelResult,
|
||||
AgentBeforeToolResult,
|
||||
@@ -789,6 +790,7 @@ export class AgentRuntime {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
lifecycle: tool.lifecycle ? { ...tool.lifecycle } : undefined,
|
||||
})),
|
||||
signal: this.abortController?.signal,
|
||||
options: mergeModelOptions(this.config.modelOptions, {
|
||||
@@ -998,7 +1000,7 @@ export class AgentRuntime {
|
||||
});
|
||||
}
|
||||
|
||||
const message = createMessage(
|
||||
let message = createMessage(
|
||||
"assistant",
|
||||
content,
|
||||
invalidToolCalls.length > 0 ? { invalidToolCalls } : undefined,
|
||||
@@ -1012,12 +1014,19 @@ export class AgentRuntime {
|
||||
message.modelInfo = { ...this.config.messageModelInfo };
|
||||
}
|
||||
for (const hook of this.hooks.afterModel) {
|
||||
const control = (await hook({
|
||||
const result = (await hook({
|
||||
snapshot: this.snapshot(),
|
||||
assistantMessage: message,
|
||||
finishReason,
|
||||
})) as AgentStopControl | undefined;
|
||||
this.applyStopControl(control);
|
||||
})) as AgentAfterModelResult | undefined;
|
||||
if (result?.message) {
|
||||
message = {
|
||||
...result.message,
|
||||
metrics: result.message.metrics ?? message.metrics,
|
||||
modelInfo: result.message.modelInfo ?? message.modelInfo,
|
||||
};
|
||||
}
|
||||
this.applyStopControl(result);
|
||||
}
|
||||
|
||||
return { message, finishReason };
|
||||
|
||||
@@ -130,6 +130,35 @@ describe("plugin-sandbox", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(dir, "plugin-model-hooks.mjs"),
|
||||
[
|
||||
"export default {",
|
||||
" name: 'sandbox-model-hooks',",
|
||||
" manifest: { capabilities: ['hooks'] },",
|
||||
" hooks: {",
|
||||
" beforeModel(ctx) {",
|
||||
" return {",
|
||||
" tools: [],",
|
||||
" messages: ctx.request.messages.concat([",
|
||||
" { id: 'docs', role: 'user', content: [{ type: 'text', text: 'TOOL DOCUMENTATION' }], createdAt: 0 },",
|
||||
" ]),",
|
||||
" };",
|
||||
" },",
|
||||
" afterModel(ctx) {",
|
||||
" return {",
|
||||
" message: {",
|
||||
" ...ctx.assistantMessage,",
|
||||
" content: [{ type: 'tool-call', toolCallId: 'xml_1', toolName: 'echo', input: { text: 'hi' } }],",
|
||||
" },",
|
||||
" };",
|
||||
" },",
|
||||
" },",
|
||||
"};",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(dir, "plugin-message-builder.mjs"),
|
||||
[
|
||||
@@ -358,6 +387,7 @@ describe("plugin-sandbox", () => {
|
||||
join(dir, "plugin.mjs"),
|
||||
join(dir, "plugin-events.mjs"),
|
||||
join(dir, "plugin-run-end.mjs"),
|
||||
join(dir, "plugin-model-hooks.mjs"),
|
||||
join(dir, "plugin-automation-events.mjs"),
|
||||
join(dir, "plugin-message-builder.mjs"),
|
||||
join(dir, "plugin-rules.mjs"),
|
||||
@@ -415,6 +445,58 @@ describe("plugin-sandbox", () => {
|
||||
expect(result).toEqual({ echoed: "ok" });
|
||||
});
|
||||
|
||||
it("round-trips beforeModel/afterModel transform results across the sandbox", async () => {
|
||||
const extension = sharedExtensions.get("sandbox-model-hooks");
|
||||
expect(extension?.name).toBe("sandbox-model-hooks");
|
||||
|
||||
const beforeResult = await extension?.hooks?.beforeModel?.({
|
||||
snapshot: makeSnapshot(),
|
||||
request: {
|
||||
systemPrompt: "base prompt",
|
||||
messages: [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: "echo",
|
||||
description: "echo",
|
||||
inputSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(beforeResult?.tools).toEqual([]);
|
||||
expect(beforeResult?.messages).toHaveLength(2);
|
||||
expect(beforeResult?.messages?.[1]?.content).toEqual([
|
||||
{ type: "text", text: "TOOL DOCUMENTATION" },
|
||||
]);
|
||||
|
||||
const afterResult = await extension?.hooks?.afterModel?.({
|
||||
snapshot: makeSnapshot(),
|
||||
assistantMessage: {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "<echo><text>hi</text></echo>" }],
|
||||
createdAt: 1,
|
||||
},
|
||||
finishReason: "stop",
|
||||
});
|
||||
expect(afterResult?.message?.content).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "xml_1",
|
||||
toolName: "echo",
|
||||
input: { text: "hi" },
|
||||
},
|
||||
]);
|
||||
expect(afterResult?.message?.id).toBe("a1");
|
||||
});
|
||||
|
||||
it("enforces hook timeout and cancels sandbox process", async () => {
|
||||
const timeoutDir = await mkdtemp(
|
||||
join(tmpdir(), "core-plugin-sandbox-timeout-"),
|
||||
|
||||
@@ -649,6 +649,78 @@ describe("SessionRuntime message preparation", () => {
|
||||
expect(configs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("chains afterModel message replacements across extensions", async () => {
|
||||
const seenBySecondHook: unknown[] = [];
|
||||
const replacer: AgentExtension = {
|
||||
name: "xml-parser-ext",
|
||||
manifest: { capabilities: ["hooks"] },
|
||||
hooks: {
|
||||
afterModel: ({ assistantMessage }) => ({
|
||||
message: {
|
||||
...assistantMessage,
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_xml_1",
|
||||
toolName: "echo",
|
||||
input: { text: "hi" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
const observer: AgentExtension = {
|
||||
name: "observer-ext",
|
||||
manifest: { capabilities: ["hooks"] },
|
||||
hooks: {
|
||||
afterModel: ({ assistantMessage }) => {
|
||||
seenBySecondHook.push(assistantMessage.content[0]?.type);
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
};
|
||||
const { deps } = makeRecordingRuntimeFactory();
|
||||
const session = new SessionRuntime(
|
||||
makeAgentConfig({ extensions: [replacer, observer] }),
|
||||
deps,
|
||||
);
|
||||
|
||||
await (
|
||||
session as unknown as {
|
||||
ensureExtensionsInitialized(): Promise<void>;
|
||||
}
|
||||
).ensureExtensionsInitialized();
|
||||
const hooks = (
|
||||
session as unknown as {
|
||||
createRuntimeHooks(): AgentRuntimeConfig["hooks"];
|
||||
}
|
||||
).createRuntimeHooks();
|
||||
const afterModel = hooks?.afterModel;
|
||||
expect(afterModel).toBeDefined();
|
||||
|
||||
const result = await afterModel?.({
|
||||
snapshot: makeSnapshot(),
|
||||
assistantMessage: {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "<echo><text>hi</text></echo>" }],
|
||||
createdAt: 1,
|
||||
},
|
||||
finishReason: "stop",
|
||||
});
|
||||
|
||||
expect(result?.message?.content).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_xml_1",
|
||||
toolName: "echo",
|
||||
input: { text: "hi" },
|
||||
},
|
||||
]);
|
||||
expect(seenBySecondHook).toEqual(["tool-call"]);
|
||||
});
|
||||
|
||||
it("adapts prepareTurn with API-safe messages for runtime compaction", async () => {
|
||||
const prepareTurn = vi.fn(() => ({
|
||||
messages: [
|
||||
|
||||
@@ -189,11 +189,20 @@ function mergeRuntimeHooks(
|
||||
return aggregate;
|
||||
},
|
||||
afterModel: async (ctx) => {
|
||||
let assistantMessage = ctx.assistantMessage;
|
||||
let aggregate:
|
||||
| Awaited<ReturnType<NonNullable<AgentRuntimeHooks["afterModel"]>>>
|
||||
| undefined;
|
||||
for (const hook of hooks) {
|
||||
const result = await hook.afterModel?.(ctx);
|
||||
if (result?.stop) return result;
|
||||
const result = await hook.afterModel?.({ ...ctx, assistantMessage });
|
||||
if (!result) continue;
|
||||
if (result.stop) return { ...aggregate, ...result };
|
||||
aggregate = { ...aggregate, ...result };
|
||||
if (result.message) {
|
||||
assistantMessage = result.message;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return aggregate;
|
||||
},
|
||||
beforeTool: async (ctx) => {
|
||||
let input = ctx.input;
|
||||
|
||||
@@ -290,6 +290,19 @@ export interface AgentAfterModelContext {
|
||||
finishReason: AgentModelFinishReason;
|
||||
}
|
||||
|
||||
export interface AgentAfterModelResult {
|
||||
stop?: boolean;
|
||||
reason?: string;
|
||||
/**
|
||||
* Replacement assistant message. When set, the runtime uses this message
|
||||
* instead of the streamed one — including its `tool-call` parts, which are
|
||||
* executed as if the model had emitted them natively. Metrics and model
|
||||
* info from the original message are preserved unless the replacement
|
||||
* carries its own.
|
||||
*/
|
||||
message?: AgentMessage;
|
||||
}
|
||||
|
||||
export interface AgentBeforeToolContext {
|
||||
snapshot: AgentRuntimeStateSnapshot;
|
||||
tool: AgentTool;
|
||||
@@ -348,7 +361,10 @@ export interface AgentRuntimeHooks {
|
||||
| Promise<AgentBeforeModelResult | undefined>;
|
||||
afterModel?: (
|
||||
context: AgentAfterModelContext,
|
||||
) => AgentStopControl | undefined | Promise<AgentStopControl | undefined>;
|
||||
) =>
|
||||
| AgentAfterModelResult
|
||||
| undefined
|
||||
| Promise<AgentAfterModelResult | undefined>;
|
||||
beforeTool?: (
|
||||
context: AgentBeforeToolContext,
|
||||
) =>
|
||||
|
||||
Reference in New Issue
Block a user