feat(agent-sdk): let agent_knowledge_recall request source chunks (#2995)

The recall API already supports `include: {chunks}`, and the TypeScript
client already exposes it as `includeChunks`/`maxChunkTokens`, but the
agent tool forwarded only `{maxTokens, types}` — so an agent had no way
to reach the raw source text a fact was extracted from, which is exactly
what "what did we actually say" questions need.

Add optional `include_chunks` / `max_chunk_tokens` parameters and pass
them through. Both are additive and off by default, so recall responses
are unchanged unless an agent asks for chunks.

Fixes #2949
This commit is contained in:
chethanuk
2026-07-31 17:12:27 +02:00
committed by GitHub
parent 79c9f4afeb
commit cd40649393
4 changed files with 80 additions and 2 deletions
+1 -1
View File
@@ -153,7 +153,7 @@ Optional settings in `~/.openclaw/openclaw.json`:
- `enableKnowledgeTools` - Register `agent_knowledge_*` tools for explicit agent-driven lookup, reflection, ingest, and knowledge-page management (default: `false`).
- `debug` - Enable debug logging (default: `false`).
When using `agent_knowledge_recall` manually, pass `max_tokens` to control how much memory text the recall response may contain. The tool has no `max_results` parameter — to cap the number of automatically injected memories, use `recallTopK` on auto-recall instead.
When using `agent_knowledge_recall` manually, pass `max_tokens` to control how much memory text the recall response may contain. The tool has no `max_results` parameter — to cap the number of automatically injected memories, use `recallTopK` on auto-recall instead. When the answer needs verbatim wording or an exact number rather than an extracted fact, pass `include_chunks: true` to also get the raw source text those memories came from, and `max_chunk_tokens` to bound it (default `8192`).
When using `agent_knowledge_reflect`, keep the default conservative settings unless you intentionally need a deeper synthesis: `budget` defaults to `low`, `max_tokens` defaults to `1024`, and `fact_types` defaults to `world`, `experience`, and `observation`. Reflect calls can be more expensive than recall because they retrieve memories and then call the configured Reflect LLM to generate an answer. For production banks, set a finite bank-level `reflect_source_facts_max_tokens` value (for example `4096` or `8192`) instead of leaving it unlimited, so ad-hoc reflection cannot pull an unbounded amount of source facts into the LLM context.
@@ -99,6 +99,14 @@ function parseRecallMaxTokens(input: unknown): number {
return n;
}
/** Undefined on absent/invalid input so the client's own chunk budget default applies. */
function parseChunkMaxTokens(input: unknown): number | undefined {
if (input === undefined || input === null) return undefined;
const n = Math.floor(Number(input));
if (!Number.isFinite(n) || n < 1) return undefined;
return n;
}
// ── Factory ────────────────────────────────────────────
/**
@@ -261,6 +269,17 @@ export function createKnowledgeTools(opts: CreateKnowledgeToolsOptions): Knowled
items: { type: "string", enum: ["world", "experience", "observation"] },
description: "Alias for fact_types.",
},
include_chunks: {
type: "boolean",
description:
"Also return the raw source text the memories were extracted from (default false). " +
"Use only when the answer needs verbatim wording, an exact quote, or an exact number " +
"that an extracted fact may have rounded or paraphrased — it costs significantly more tokens.",
},
max_chunk_tokens: {
type: "number",
description: "Token budget for the returned source chunks (default 8192).",
},
},
required: ["query"],
},
@@ -273,6 +292,8 @@ export function createKnowledgeTools(opts: CreateKnowledgeToolsOptions): Knowled
const result = await client.recall(bankId, params.query as string, {
maxTokens,
types,
includeChunks: params.include_chunks === true,
maxChunkTokens: parseChunkMaxTokens(params.max_chunk_tokens),
});
return ok(result);
},
@@ -206,6 +206,63 @@ describe("createKnowledgeTools", () => {
expect(body.max_tokens).toBe(512);
});
it("recall omits source chunks unless include_chunks is exactly true", async () => {
const tool = tools.find((t) => t.name === "agent_knowledge_recall")!;
// Omitted, false, and a truthy-string "false" must all leave chunks off.
for (const params of [
{ query: "no chunks" },
{ query: "no chunks", include_chunks: false },
{ query: "no chunks", include_chunks: "false" },
// A chunk budget alone must not switch chunks on.
{ query: "no chunks", max_chunk_tokens: 2048 },
]) {
mockFetch.mockReset();
mockFetch.mockReturnValueOnce(mockResponse({ results: [{ text: "found" }] }));
await tool.execute(params);
const body = await getBody(mockFetch.mock.calls[0]);
expect(body.include?.chunks, JSON.stringify(params)).toBeUndefined();
}
});
it("recall include_chunks requests source chunks and returns them to the agent", async () => {
mockFetch.mockReturnValueOnce(
mockResponse({
results: [{ text: "found" }],
chunks: { c1: { chunk_text: "the fee is 4.375%", truncated: false } },
})
);
const tool = tools.find((t) => t.name === "agent_knowledge_recall")!;
const result = await tool.execute({ query: "exact wording", include_chunks: true });
const body = await getBody(mockFetch.mock.calls[0]);
expect(body.include.chunks).toEqual({ max_tokens: 8192 });
// The chunks must survive into the tool output, not just the request.
expect(JSON.parse(result.content[0].text).chunks.c1.chunk_text).toBe("the fee is 4.375%");
});
it("recall falls back to the client's chunk budget on unusable max_chunk_tokens", async () => {
const tool = tools.find((t) => t.name === "agent_knowledge_recall")!;
for (const [maxChunkTokens, expected] of [
[2048, 2048],
[0, 8192],
[-5, 8192],
["not-a-number", 8192],
] as const) {
mockFetch.mockReset();
mockFetch.mockReturnValueOnce(mockResponse({ results: [{ text: "found" }] }));
await tool.execute({
query: "exact wording",
include_chunks: true,
max_chunk_tokens: maxChunkTokens,
});
const body = await getBody(mockFetch.mock.calls[0]);
expect(body.include.chunks.max_tokens, String(maxChunkTokens)).toBe(expected);
}
});
it("reflect sends POST with conservative defaults", async () => {
mockFetch.mockReturnValueOnce(mockResponse({ text: "answer" }));
@@ -148,7 +148,7 @@ Optional settings in `~/.openclaw/openclaw.json`:
- `enableKnowledgeTools` - Register `agent_knowledge_*` tools for explicit agent-driven lookup, reflection, ingest, and knowledge-page management (default: `false`).
- `debug` - Enable debug logging (default: `false`).
When using `agent_knowledge_recall` manually, pass `max_tokens` to control how much memory text the recall response may contain. The tool has no `max_results` parameter — to cap the number of automatically injected memories, use `recallTopK` on auto-recall instead.
When using `agent_knowledge_recall` manually, pass `max_tokens` to control how much memory text the recall response may contain. The tool has no `max_results` parameter — to cap the number of automatically injected memories, use `recallTopK` on auto-recall instead. When the answer needs verbatim wording or an exact number rather than an extracted fact, pass `include_chunks: true` to also get the raw source text those memories came from, and `max_chunk_tokens` to bound it (default `8192`).
When using `agent_knowledge_reflect`, keep the default conservative settings unless you intentionally need a deeper synthesis: `budget` defaults to `low`, `max_tokens` defaults to `1024`, and `fact_types` defaults to `world`, `experience`, and `observation`. Reflect calls can be more expensive than recall because they retrieve memories and then call the configured Reflect LLM to generate an answer. For production banks, set a finite bank-level `reflect_source_facts_max_tokens` value (for example `4096` or `8192`) instead of leaving it unlimited, so ad-hoc reflection cannot pull an unbounded amount of source facts into the LLM context.