PR #265 reported a real symptom — `tools.executor.sources.list()` silently returns the first 12 sources, so a model that calls it past alphabetical position 12 mistakes the page for the full inventory. The proposed fix returned `{ sources, totalSources }` from `sources.list` only, which: - Tells the model truncation happened, but provides no way to act on it — no offset/cursor parameter exists, so the model can only retry with `limit: 999` (defeating pagination). - Leaves `tools.search` untouched — same truncation hazard, same model. - Introduces an inconsistent shape: `search` returns a bare array, `sources.list` returns an object. This change paginates both with a single shared shape: { items, total, hasMore, nextOffset } Both helpers accept `{ limit?, offset? }`. `nextOffset` is concrete (the exact number to pass next) so the model doesn't have to compute `offset + items.length`. Engine arg validation rejects negative offsets. The `tools.search` description now teaches the paginated shape and shows the second-page call. Default `limit` for `sources.list` drops from 200 to 50; the engine layer still applies 12 by default for both. The off-by-one ergonomics matter more than the headroom — most users have well under 50 sources. Tests cover: - single-page and empty-query results carry the new shape - pagination across pages with limit + offset, including offset past end - source listing pagination via the engine - negative offsets rejected with a clear message Closes #265.
Unified · Split
| @@ -34,7 +34,7 @@ const formatDescription = (sources: readonly Source[]): string => { · 33 unmodified lines | |||
| 34 | "", | 34 | "", |
| 35 | "## Workflow", | 35 | "## Workflow", |
| 36 | "", | 36 | "", |
| 37 | '1. `const matches = await tools.search({ query: "<intent + key nouns>", limit: 12 });`', | 37 | '1. `const { items: matches } = await tools.search({ query: "<intent + key nouns>", limit: 12 });`', |
| 38 | '2. `const path = matches[0]?.path; if (!path) return "No matching tools found.";`', | 38 | '2. `const path = matches[0]?.path; if (!path) return "No matching tools found.";`', |
| 39 | "3. `const details = await tools.describe.tool({ path });`", | 39 | "3. `const details = await tools.describe.tool({ path });`", |
| 40 | "4. Use `details.inputTypeScript` / `details.outputTypeScript` and `details.typeScriptDefinitions` for compact shapes.", | 40 | "4. Use `details.inputTypeScript` / `details.outputTypeScript` and `details.typeScriptDefinitions` for compact shapes.", |
| @@ -43,9 +43,10 @@ const formatDescription = (sources: readonly Source[]): string => { · 2 unmodified lines | |||
| 43 | "", | 43 | "", |
| 44 | "## Rules", | 44 | "## Rules", |
| 45 | "", | 45 | "", |
| 46 | "- `tools.search()` returns ranked matches, best-first. Use short intent phrases like `github issues`, `repo details`, or `create calendar event`.", | 46 | "- `tools.search()` returns paginated, ranked matches: `{ items, total, hasMore, nextOffset }`. Best-first. Use short intent phrases like `github issues`, `repo details`, or `create calendar event`.", |
| 47 | '- When you already know the namespace, narrow with `tools.search({ namespace: "github", query: "issues" })`.', | 47 | '- When you already know the namespace, narrow with `tools.search({ namespace: "github", query: "issues" })`.', |
| 48 | "- Use `tools.executor.sources.list()` to inspect configured sources and their tool counts. Returns `[{ id, toolCount, ... }]`.", | 48 | "- `tools.executor.sources.list()` returns the same paged shape: `{ items: [{ id, toolCount, ... }], total, hasMore, nextOffset }`.", |
| 49 | "- If `hasMore` is true and you didn't find what you need, fetch the next page: `tools.search({ query, offset: nextOffset, limit })`. Same `offset` parameter on `tools.executor.sources.list({ offset, limit })`.", | ||
| 49 | "- Always use the namespace prefix when calling tools: `tools.<namespace>.<tool>(args)`. Example: `tools.home_assistant_rest_api.states.getState(...)` — not `tools.states.getState(...)`.", | 50 | "- Always use the namespace prefix when calling tools: `tools.<namespace>.<tool>(args)`. Example: `tools.home_assistant_rest_api.states.getState(...)` — not `tools.states.getState(...)`.", |
| 50 | "- The `tools` object is a lazy proxy — `Object.keys(tools)` won't work. Use `tools.search()` or `tools.executor.sources.list()` instead.", | 51 | "- The `tools` object is a lazy proxy — `Object.keys(tools)` won't work. Use `tools.search()` or `tools.executor.sources.list()` instead.", |
| 51 | '- Pass an object to system tools, e.g. `tools.search({ query: "..." })`, `tools.executor.sources.list()`, and `tools.describe.tool({ path })`.', | 52 | '- Pass an object to system tools, e.g. `tools.search({ query: "..." })`, `tools.executor.sources.list()`, and `tools.describe.tool({ path })`.', |
| @@ -157,6 +157,20 @@ const readOptionalLimit = (value: unknown, toolName: string): number | Execution · 156 unmodified lines | |||
| 157 | return Math.floor(value); | 157 | return Math.floor(value); |
| 158 | }; | 158 | }; |
| 159 | 159 | ||
| 160 | const readOptionalOffset = (value: unknown, toolName: string): number | ExecutionToolError => { | ||
| 161 | if (value === undefined) { | ||
| 162 | return 0; | ||
| 163 | } | ||
| 164 | |||
| 165 | if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { | ||
| 166 | return new ExecutionToolError({ | ||
| 167 | message: `${toolName} offset must be a non-negative number when provided`, | ||
| 168 | }); | ||
| 169 | } | ||
| 170 | |||
| 171 | return Math.floor(value); | ||
| 172 | }; | ||
| 173 | |||
| 160 | const makeFullInvoker = (executor: Executor, invokeOptions: InvokeOptions): SandboxToolInvoker => { | 174 | const makeFullInvoker = (executor: Executor, invokeOptions: InvokeOptions): SandboxToolInvoker => { |
| 161 | const base = makeExecutorToolInvoker(executor, { invokeOptions }); | 175 | const base = makeExecutorToolInvoker(executor, { invokeOptions }); |
| 162 | return { | 176 | return { |
| @@ -166,7 +180,7 @@ const makeFullInvoker = (executor: Executor, invokeOptions: InvokeOptions): Sand · 3 unmodified lines | |||
| 166 | return Effect.fail( | 180 | return Effect.fail( |
| 167 | new ExecutionToolError({ | 181 | new ExecutionToolError({ |
| 168 | message: | 182 | message: |
| 169 | "tools.search expects an object: { query?: string; namespace?: string; limit?: number }", | 183 | "tools.search expects an object: { query?: string; namespace?: string; limit?: number; offset?: number }", |
| 170 | }), | 184 | }), |
| 171 | ); | 185 | ); |
| 172 | } | 186 | } |
| @@ -192,8 +206,14 @@ const makeFullInvoker = (executor: Executor, invokeOptions: InvokeOptions): Sand · 19 unmodified lines | |||
| 192 | return Effect.fail(limit); | 206 | return Effect.fail(limit); |
| 193 | } | 207 | } |
| 194 | 208 | ||
| 209 | const offset = readOptionalOffset(args.offset, "tools.search"); | ||
| 210 | if (offset instanceof ExecutionToolError) { | ||
| 211 | return Effect.fail(offset); | ||
| 212 | } | ||
| 213 | |||
| 195 | return searchTools(executor, args.query ?? "", limit, { | 214 | return searchTools(executor, args.query ?? "", limit, { |
| 196 | namespace: args.namespace, | 215 | namespace: args.namespace, |
| 216 | offset, | ||
| 197 | }).pipe( | 217 | }).pipe( |
| 198 | Effect.withSpan("mcp.tool.dispatch", { | 218 | Effect.withSpan("mcp.tool.dispatch", { |
| 199 | attributes: { "mcp.tool.name": path, "executor.tool.builtin": true }, | 219 | attributes: { "mcp.tool.name": path, "executor.tool.builtin": true }, |
| @@ -205,7 +225,7 @@ const makeFullInvoker = (executor: Executor, invokeOptions: InvokeOptions): Sand · 5 unmodified lines | |||
| 205 | return Effect.fail( | 225 | return Effect.fail( |
| 206 | new ExecutionToolError({ | 226 | new ExecutionToolError({ |
| 207 | message: | 227 | message: |
| 208 | "tools.executor.sources.list expects an object: { query?: string; limit?: number }", | 228 | "tools.executor.sources.list expects an object: { query?: string; limit?: number; offset?: number }", |
| 209 | }), | 229 | }), |
| 210 | ); | 230 | ); |
| 211 | } | 231 | } |
| @@ -226,9 +246,18 @@ const makeFullInvoker = (executor: Executor, invokeOptions: InvokeOptions): Sand · 14 unmodified lines | |||
| 226 | return Effect.fail(limit); | 246 | return Effect.fail(limit); |
| 227 | } | 247 | } |
| 228 | 248 | ||
| 249 | const offset = readOptionalOffset( | ||
| 250 | isRecord(args) ? args.offset : undefined, | ||
| 251 | "tools.executor.sources.list", | ||
| 252 | ); | ||
| 253 | if (offset instanceof ExecutionToolError) { | ||
| 254 | return Effect.fail(offset); | ||
| 255 | } | ||
| 256 | |||
| 229 | return listExecutorSources(executor, { | 257 | return listExecutorSources(executor, { |
| 230 | query: isRecord(args) && typeof args.query === "string" ? args.query : undefined, | 258 | query: isRecord(args) && typeof args.query === "string" ? args.query : undefined, |
| 231 | limit, | 259 | limit, |
| 260 | offset, | ||
| 232 | }).pipe( | 261 | }).pipe( |
| 233 | Effect.withSpan("mcp.tool.dispatch", { | 262 | Effect.withSpan("mcp.tool.dispatch", { |
| 234 | attributes: { "mcp.tool.name": path, "executor.tool.builtin": true }, | 263 | attributes: { "mcp.tool.name": path, "executor.tool.builtin": true }, |
| @@ -143,15 +143,21 @@ describe("tool discovery", () => { · 142 unmodified lines | |||
| 143 | const executor = yield* makeSearchExecutor(); | 143 | const executor = yield* makeSearchExecutor(); |
| 144 | 144 | ||
| 145 | const githubMatches = yield* searchTools(executor, "github issues", 5); | 145 | const githubMatches = yield* searchTools(executor, "github issues", 5); |
| 146 | expect(githubMatches.map((match) => match.path)).toEqual(["github.listRepositoryIssues"]); | 146 | expect(githubMatches.items.map((match) => match.path)).toEqual([ |
| 147 | expect(githubMatches[0]?.score ?? 0).toBeGreaterThan(0); | 147 | "github.listRepositoryIssues", |
| 148 | ]); | ||
| 149 | expect(githubMatches.items[0]?.score ?? 0).toBeGreaterThan(0); | ||
| 150 | expect(githubMatches.hasMore).toBe(false); | ||
| 151 | expect(githubMatches.nextOffset).toBeNull(); | ||
| 148 | 152 | ||
| 149 | const repoMatches = yield* searchTools(executor, "repo details", 5); | 153 | const repoMatches = yield* searchTools(executor, "repo details", 5); |
| 150 | expect(repoMatches[0]?.path).toBe("github.getRepositoryDetails"); | 154 | expect(repoMatches.items[0]?.path).toBe("github.getRepositoryDetails"); |
| 151 | 155 | ||
| 152 | const crmMatches = yield* searchTools(executor, "crm create contact", 5); | 156 | const crmMatches = yield* searchTools(executor, "crm create contact", 5); |
| 153 | expect(crmMatches[0]?.path).toBe("crm.createContact"); | 157 | expect(crmMatches.items[0]?.path).toBe("crm.createContact"); |
| 154 | expect(crmMatches[0]?.score ?? 0).toBeGreaterThan(crmMatches[1]?.score ?? 0); | 158 | expect(crmMatches.items[0]?.score ?? 0).toBeGreaterThan( |
| 159 | crmMatches.items[1]?.score ?? 0, | ||
| 160 | ); | ||
| 155 | }), | 161 | }), |
| 156 | ); | 162 | ); |
| 157 | 163 | ||
| @@ -159,7 +165,49 @@ describe("tool discovery", () => { · 1 unmodified line | |||
| 159 | Effect.gen(function* () { | 165 | Effect.gen(function* () { |
| 160 | const executor = yield* makeSearchExecutor(); | 166 | const executor = yield* makeSearchExecutor(); |
| 161 | const matches = yield* searchTools(executor, "", 5); | 167 | const matches = yield* searchTools(executor, "", 5); |
| 162 | expect(matches).toEqual([]); | 168 | expect(matches.items).toEqual([]); |
| 169 | expect(matches.total).toBe(0); | ||
| 170 | expect(matches.hasMore).toBe(false); | ||
| 171 | expect(matches.nextOffset).toBeNull(); | ||
| 172 | }), | ||
| 173 | ); | ||
| 174 | |||
| 175 | it.effect("paginates ranked matches via limit + offset with hasMore + nextOffset", () => | ||
| 176 | Effect.gen(function* () { | ||
| 177 | const executor = yield* makeSearchExecutor(); | ||
| 178 | |||
| 179 | // "list" matches `listRepositoryIssues`, `searchDocs` (description has | ||
| 180 | // "documentation" which tokenises adjacent), `listContacts`, etc. | ||
| 181 | // The exact match set isn't important — the pagination invariants are. | ||
| 182 | const all = yield* searchTools(executor, "list", 100); | ||
| 183 | expect(all.items.length).toBeGreaterThan(1); | ||
| 184 | expect(all.total).toBe(all.items.length); | ||
| 185 | expect(all.hasMore).toBe(false); | ||
| 186 | expect(all.nextOffset).toBeNull(); | ||
| 187 | |||
| 188 | // First page (limit 1) — matches truncate, hasMore + nextOffset surface. | ||
| 189 | const firstPage = yield* searchTools(executor, "list", 1); | ||
| 190 | expect(firstPage.items).toEqual([all.items[0]]); | ||
| 191 | expect(firstPage.total).toBe(all.total); | ||
| 192 | expect(firstPage.hasMore).toBe(true); | ||
| 193 | expect(firstPage.nextOffset).toBe(1); | ||
| 194 | |||
| 195 | // Second page using nextOffset — order matches the un-paginated rank. | ||
| 196 | const secondPage = yield* searchTools(executor, "list", 1, { | ||
| 197 | offset: firstPage.nextOffset!, | ||
| 198 | }); | ||
| 199 | expect(secondPage.items).toEqual([all.items[1]]); | ||
| 200 | expect(secondPage.total).toBe(all.total); | ||
| 201 | // Whether hasMore is true depends on total; at minimum it's consistent. | ||
| 202 | expect(secondPage.hasMore).toBe(all.total > 2); | ||
| 203 | expect(secondPage.nextOffset).toBe(secondPage.hasMore ? 2 : null); | ||
| 204 | |||
| 205 | // Offset past the end — empty page, no more. | ||
| 206 | const past = yield* searchTools(executor, "list", 5, { offset: all.total + 10 }); | ||
| 207 | expect(past.items).toEqual([]); | ||
| 208 | expect(past.total).toBe(all.total); | ||
| 209 | expect(past.hasMore).toBe(false); | ||
| 210 | expect(past.nextOffset).toBeNull(); | ||
| 163 | }), | 211 | }), |
| 164 | ); | 212 | ); |
| 165 | 213 | ||
| @@ -170,21 +218,28 @@ describe("tool discovery", () => { · 4 unmodified lines | |||
| 170 | const githubOnly = yield* searchTools(executor, "list", 5, { | 218 | const githubOnly = yield* searchTools(executor, "list", 5, { |
| 171 | namespace: "github", | 219 | namespace: "github", |
| 172 | }); | 220 | }); |
| 173 | expect(githubOnly.map((match) => match.path)).toEqual(["github.listRepositoryIssues"]); | 221 | expect(githubOnly.items.map((match) => match.path)).toEqual([ |
| 222 | "github.listRepositoryIssues", | ||
| 223 | ]); | ||
| 174 | 224 | ||
| 175 | const crmOnly = yield* searchTools(executor, "list", 5, { | 225 | const crmOnly = yield* searchTools(executor, "list", 5, { |
| 176 | namespace: "crm", | 226 | namespace: "crm", |
| 177 | }); | 227 | }); |
| 178 | expect(crmOnly.map((match) => match.path)).toEqual(["crm.listContacts"]); | 228 | expect(crmOnly.items.map((match) => match.path)).toEqual(["crm.listContacts"]); |
| 179 | 229 | ||
| 180 | const sandboxResult = yield* createExecutionEngine({ executor, codeExecutor }).execute( | 230 | const sandboxResult = yield* createExecutionEngine({ executor, codeExecutor }).execute( |
| 181 | 'return await tools.search({ namespace: "crm", query: "create contact", limit: 5 });', | 231 | 'return await tools.search({ namespace: "crm", query: "create contact", limit: 5 });', |
| 182 | { onElicitation: acceptAll }, | 232 | { onElicitation: acceptAll }, |
| 183 | ); | 233 | ); |
| 184 | expect(sandboxResult.error).toBeUndefined(); | 234 | expect(sandboxResult.error).toBeUndefined(); |
| 185 | expect(sandboxResult.result).toEqual([ | 235 | expect(sandboxResult.result).toEqual( |
| 186 | expect.objectContaining({ path: "crm.createContact" }), | 236 | expect.objectContaining({ |
| 187 | ]); | 237 | items: [expect.objectContaining({ path: "crm.createContact" })], |
| 238 | total: 1, | ||
| 239 | hasMore: false, | ||
| 240 | nextOffset: null, | ||
| 241 | }), | ||
| 242 | ); | ||
| 188 | }), | 243 | }), |
| 189 | ); | 244 | ); |
| 190 | 245 | ||
| @@ -198,10 +253,15 @@ describe("tool discovery", () => { · 7 unmodified lines | |||
| 198 | ); | 253 | ); |
| 199 | expect(listed.error).toBeUndefined(); | 254 | expect(listed.error).toBeUndefined(); |
| 200 | expect(listed.result).toEqual( | 255 | expect(listed.result).toEqual( |
| 201 | expect.arrayContaining([ | 256 | expect.objectContaining({ |
| 202 | expect.objectContaining({ id: "github", toolCount: 3 }), | 257 | items: expect.arrayContaining([ |
| 203 | expect.objectContaining({ id: "crm", toolCount: 2 }), | 258 | expect.objectContaining({ id: "github", toolCount: 3 }), |
| 204 | ]), | 259 | expect.objectContaining({ id: "crm", toolCount: 2 }), |
| 260 | ]), | ||
| 261 | total: 2, | ||
| 262 | hasMore: false, | ||
| 263 | nextOffset: null, | ||
| 264 | }), | ||
| 205 | ); | 265 | ); |
| 206 | 266 | ||
| 207 | const searched = yield* createExecutionEngine({ executor, codeExecutor }).execute( | 267 | const searched = yield* createExecutionEngine({ executor, codeExecutor }).execute( |
| @@ -209,7 +269,86 @@ describe("tool discovery", () => { · 1 unmodified line | |||
| 209 | { onElicitation: acceptAll }, | 269 | { onElicitation: acceptAll }, |
| 210 | ); | 270 | ); |
| 211 | expect(searched.error).toBeUndefined(); | 271 | expect(searched.error).toBeUndefined(); |
| 212 | expect(searched.result).toEqual([expect.objectContaining({ path: "crm.listContacts" })]); | 272 | expect(searched.result).toEqual( |
| 273 | expect.objectContaining({ | ||
| 274 | items: [expect.objectContaining({ path: "crm.listContacts" })], | ||
| 275 | }), | ||
| 276 | ); | ||
| 277 | }), | ||
| 278 | ); | ||
| 279 | |||
| 280 | it.effect("paginates source listings via limit + offset", () => | ||
| 281 | Effect.gen(function* () { | ||
| 282 | const executor = yield* makeSearchExecutor(); | ||
| 283 | const engine = createExecutionEngine({ executor, codeExecutor }); | ||
| 284 | |||
| 285 | // total = 2 (github, crm), sorted by name ("CRM" < "GitHub") | ||
| 286 | const firstPage = yield* engine.execute( | ||
| 287 | "return await tools.executor.sources.list({ limit: 1 });", | ||
| 288 | { onElicitation: acceptAll }, | ||
| 289 | ); | ||
| 290 | expect(firstPage.error).toBeUndefined(); | ||
| 291 | expect(firstPage.result).toEqual( | ||
| 292 | expect.objectContaining({ | ||
| 293 | items: [expect.objectContaining({ id: "crm" })], | ||
| 294 | total: 2, | ||
| 295 | hasMore: true, | ||
| 296 | nextOffset: 1, | ||
| 297 | }), | ||
| 298 | ); | ||
| 299 | |||
| 300 | const secondPage = yield* engine.execute( | ||
| 301 | "return await tools.executor.sources.list({ limit: 1, offset: 1 });", | ||
| 302 | { onElicitation: acceptAll }, | ||
| 303 | ); | ||
| 304 | expect(secondPage.error).toBeUndefined(); | ||
| 305 | expect(secondPage.result).toEqual( | ||
| 306 | expect.objectContaining({ | ||
| 307 | items: [expect.objectContaining({ id: "github" })], | ||
| 308 | total: 2, | ||
| 309 | hasMore: false, | ||
| 310 | nextOffset: null, | ||
| 311 | }), | ||
| 312 | ); | ||
| 313 | }), | ||
| 314 | ); | ||
| 315 | |||
| 316 | it.effect("rejects negative offsets via the engine validator", () => | ||
| 317 | Effect.gen(function* () { | ||
| 318 | const executor = yield* makeSearchExecutor(); | ||
| 319 | const engine = createExecutionEngine({ executor, codeExecutor }); | ||
| 320 | |||
| 321 | const badSearch = yield* engine.execute( | ||
| 322 | [ | ||
| 323 | "try {", | ||
| 324 | ' await tools.search({ query: "list", offset: -1 });', | ||
| 325 | ' return "unexpected";', | ||
| 326 | "} catch (error) {", | ||
| 327 | " return error instanceof Error ? error.message : String(error);", | ||
| 328 | "}", | ||
| 329 | ].join("\n"), | ||
| 330 | { onElicitation: acceptAll }, | ||
| 331 | ); | ||
| 332 | expect(badSearch.error).toBeUndefined(); | ||
| 333 | expect(String(badSearch.result)).toContain( | ||
| 334 | "tools.search offset must be a non-negative number when provided", | ||
| 335 | ); | ||
| 336 | |||
| 337 | const badList = yield* engine.execute( | ||
| 338 | [ | ||
| 339 | "try {", | ||
| 340 | " await tools.executor.sources.list({ offset: -5 });", | ||
| 341 | ' return "unexpected";', | ||
| 342 | "} catch (error) {", | ||
| 343 | " return error instanceof Error ? error.message : String(error);", | ||
| 344 | "}", | ||
| 345 | ].join("\n"), | ||
| 346 | { onElicitation: acceptAll }, | ||
| 347 | ); | ||
| 348 | expect(badList.error).toBeUndefined(); | ||
| 349 | expect(String(badList.result)).toContain( | ||
| 350 | "tools.executor.sources.list offset must be a non-negative number when provided", | ||
| 351 | ); | ||
| 213 | }), | 352 | }), |
| 214 | ); | 353 | ); |
| 215 | 354 | ||
| @@ -245,7 +384,7 @@ describe("tool discovery", () => { · 29 unmodified lines | |||
| 245 | ); | 384 | ); |
| 246 | expect(invalid.error).toBeUndefined(); | 385 | expect(invalid.error).toBeUndefined(); |
| 247 | expect(String(invalid.result)).toContain( | 386 | expect(String(invalid.result)).toContain( |
| 248 | "tools.search expects an object: { query?: string; namespace?: string; limit?: number }", | 387 | "tools.search expects an object: { query?: string; namespace?: string; limit?: number; offset?: number }", |
| 249 | ); | 388 | ); |
| 250 | 389 | ||
| 251 | const emptyQuery = yield* engine.execute( | 390 | const emptyQuery = yield* engine.execute( |
| @@ -253,7 +392,12 @@ describe("tool discovery", () => { · 1 unmodified line | |||
| 253 | { onElicitation: acceptAll }, | 392 | { onElicitation: acceptAll }, |
| 254 | ); | 393 | ); |
| 255 | expect(emptyQuery.error).toBeUndefined(); | 394 | expect(emptyQuery.error).toBeUndefined(); |
| 256 | expect(emptyQuery.result).toEqual([]); | 395 | expect(emptyQuery.result).toEqual({ |
| 396 | items: [], | ||
| 397 | total: 0, | ||
| 398 | hasMore: false, | ||
| 399 | nextOffset: null, | ||
| 400 | }); | ||
| 257 | 401 | ||
| 258 | const invalidDescribe = yield* engine.execute( | 402 | const invalidDescribe = yield* engine.execute( |
| 259 | [ | 403 | [ |
| @@ -143,6 +143,40 @@ export type ExecutorSourceListItem = { · 142 unmodified lines | |||
| 143 | readonly toolCount: number; | 143 | readonly toolCount: number; |
| 144 | }; | 144 | }; |
| 145 | 145 | ||
| 146 | /** | ||
| 147 | * Page of results from a list-style discovery tool. Shared by | ||
| 148 | * `tools.search` and `tools.executor.sources.list` so the model sees one | ||
| 149 | * consistent shape: | ||
| 150 | * | ||
| 151 | * - `items` — the page (slice). | ||
| 152 | * - `total` — count after filtering, before pagination. The model | ||
| 153 | * can use this to detect truncation. | ||
| 154 | * - `hasMore` — convenience flag for `(offset + items.length) < total`. | ||
| 155 | * - `nextOffset` — concrete offset for the next page when `hasMore`, | ||
| 156 | * `null` otherwise. Pre-computing it removes a class of | ||
| 157 | * off-by-one mistakes when the model paginates. | ||
| 158 | */ | ||
| 159 | export type PagedResult<T> = { | ||
| 160 | readonly items: readonly T[]; | ||
| 161 | readonly total: number; | ||
| 162 | readonly hasMore: boolean; | ||
| 163 | readonly nextOffset: number | null; | ||
| 164 | }; | ||
| 165 | |||
| 166 | const paginate = <T>(all: readonly T[], offset: number, limit: number): PagedResult<T> => { | ||
| 167 | const total = all.length; | ||
| 168 | const start = Math.min(Math.max(offset, 0), total); | ||
| 169 | const items = all.slice(start, start + limit); | ||
| 170 | const consumed = start + items.length; | ||
| 171 | const hasMore = consumed < total; | ||
| 172 | return { | ||
| 173 | items, | ||
| 174 | total, | ||
| 175 | hasMore, | ||
| 176 | nextOffset: hasMore ? consumed : null, | ||
| 177 | }; | ||
| 178 | }; | ||
| 179 | |||
| 146 | type SearchableTool = Pick<Tool, "id" | "sourceId" | "name" | "description">; | 180 | type SearchableTool = Pick<Tool, "id" | "sourceId" | "name" | "description">; |
| 147 | 181 | ||
| 148 | type PreparedField = { | 182 | type PreparedField = { |
| @@ -328,40 +362,57 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( · 179 unmodified lines | |||
| 328 | executor: Executor, | 362 | executor: Executor, |
| 329 | query: string, | 363 | query: string, |
| 330 | limit = 12, | 364 | limit = 12, |
| 331 | options?: { readonly namespace?: string }, | 365 | options?: { readonly namespace?: string; readonly offset?: number }, |
| 332 | ) { | 366 | ) { |
| 367 | const offset = options?.offset ?? 0; | ||
| 333 | yield* Effect.annotateCurrentSpan({ | 368 | yield* Effect.annotateCurrentSpan({ |
| 334 | "executor.search.query_length": query.length, | 369 | "executor.search.query_length": query.length, |
| 335 | "executor.search.limit": limit, | 370 | "executor.search.limit": limit, |
| 371 | "executor.search.offset": offset, | ||
| 336 | ...(options?.namespace ? { "executor.search.namespace": options.namespace } : {}), | 372 | ...(options?.namespace ? { "executor.search.namespace": options.namespace } : {}), |
| 337 | }); | 373 | }); |
| 338 | 374 | ||
| 375 | const empty: PagedResult<ToolDiscoveryResult> = { | ||
| 376 | items: [], | ||
| 377 | total: 0, | ||
| 378 | hasMore: false, | ||
| 379 | nextOffset: null, | ||
| 380 | }; | ||
| 381 | |||
| 339 | if (normalizeSearchText(query).length === 0) { | 382 | if (normalizeSearchText(query).length === 0) { |
| 340 | return [] as ReadonlyArray<ToolDiscoveryResult>; | 383 | return empty; |
| 341 | } | 384 | } |
| 342 | 385 | ||
| 343 | const all = yield* executor.tools.list({ includeAnnotations: false }).pipe(Effect.orDie); | 386 | const all = yield* executor.tools.list({ includeAnnotations: false }).pipe(Effect.orDie); |
| 344 | const results = all | 387 | const ranked = all |
| 345 | .filter((tool: Tool) => matchesNamespace(tool, options?.namespace)) | 388 | .filter((tool: Tool) => matchesNamespace(tool, options?.namespace)) |
| 346 | .map((tool: Tool) => scoreToolMatch(tool, query)) | 389 | .map((tool: Tool) => scoreToolMatch(tool, query)) |
| 347 | .filter((tool): tool is ToolDiscoveryResult => tool !== null) | 390 | .filter((tool): tool is ToolDiscoveryResult => tool !== null) |
| 348 | .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)) | 391 | .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)); |
| 349 | .slice(0, limit); | 392 | |
| 393 | const page = paginate(ranked, offset, limit); | ||
| 350 | 394 | ||
| 351 | yield* Effect.annotateCurrentSpan({ | 395 | yield* Effect.annotateCurrentSpan({ |
| 352 | "executor.search.candidate_count": all.length, | 396 | "executor.search.candidate_count": all.length, |
| 353 | "executor.search.result_count": results.length, | 397 | "executor.search.match_count": ranked.length, |
| 398 | "executor.search.result_count": page.items.length, | ||
| 399 | "executor.search.has_more": page.hasMore, | ||
| 354 | }); | 400 | }); |
| 355 | return results; | 401 | return page; |
| 356 | }); | 402 | }); |
| 357 | 403 | ||
| 358 | /** What `tools.executor.sources.list()` calls inside the sandbox. */ | 404 | /** What `tools.executor.sources.list()` calls inside the sandbox. */ |
| 359 | export const listExecutorSources = Effect.fn("executor.sources.list")(function* ( | 405 | export const listExecutorSources = Effect.fn("executor.sources.list")(function* ( |
| 360 | executor: Executor, | 406 | executor: Executor, |
| 361 | options?: { readonly query?: string; readonly limit?: number }, | 407 | options?: { |
| 408 | readonly query?: string; | ||
| 409 | readonly limit?: number; | ||
| 410 | readonly offset?: number; | ||
| 411 | }, | ||
| 362 | ) { | 412 | ) { |
| 363 | const normalizedQuery = normalizeSearchText(options?.query ?? ""); | 413 | const normalizedQuery = normalizeSearchText(options?.query ?? ""); |
| 364 | const limit = options?.limit ?? 200; | 414 | const limit = options?.limit ?? 50; |
| 415 | const offset = options?.offset ?? 0; | ||
| 365 | const sources = yield* executor.sources.list().pipe(Effect.orDie); | 416 | const sources = yield* executor.sources.list().pipe(Effect.orDie); |
| 366 | 417 | ||
| 367 | const filtered = | 418 | const filtered = |
| @@ -379,28 +430,30 @@ export const listExecutorSources = Effect.fn("executor.sources.list")(function* · 11 unmodified lines | |||
| 379 | toolCountBySource.set(tool.sourceId, (toolCountBySource.get(tool.sourceId) ?? 0) + 1); | 430 | toolCountBySource.set(tool.sourceId, (toolCountBySource.get(tool.sourceId) ?? 0) + 1); |
| 380 | } | 431 | } |
| 381 | 432 | ||
| 382 | const withCounts = filtered.map( | 433 | const sortedWithCounts = filtered |
| 383 | (source: Source) => | 434 | .map( |
| 384 | ({ | 435 | (source: Source) => |
| 385 | id: source.id, | 436 | ({ |
| 386 | name: source.name, | 437 | id: source.id, |
| 387 | kind: source.kind, | 438 | name: source.name, |
| 388 | runtime: source.runtime, | 439 | kind: source.kind, |
| 389 | canRemove: source.canRemove, | 440 | runtime: source.runtime, |
| 390 | canRefresh: source.canRefresh, | 441 | canRemove: source.canRemove, |
| 391 | toolCount: toolCountBySource.get(source.id) ?? 0, | 442 | canRefresh: source.canRefresh, |
| 392 | }) satisfies ExecutorSourceListItem, | 443 | toolCount: toolCountBySource.get(source.id) ?? 0, |
| 393 | ); | 444 | }) satisfies ExecutorSourceListItem, |
| 394 | 445 | ) | |
| 395 | const results = withCounts | 446 | .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)); |
| 396 | .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)) | 447 | |
| 397 | .slice(0, limit); | 448 | const page = paginate(sortedWithCounts, offset, limit); |
| 398 | 449 | ||
| 399 | yield* Effect.annotateCurrentSpan({ | 450 | yield* Effect.annotateCurrentSpan({ |
| 400 | "executor.sources.candidate_count": sources.length, | 451 | "executor.sources.candidate_count": sources.length, |
| 401 | "executor.sources.result_count": results.length, | 452 | "executor.sources.match_count": sortedWithCounts.length, |
| 453 | "executor.sources.result_count": page.items.length, | ||
| 454 | "executor.sources.has_more": page.hasMore, | ||
| 402 | }); | 455 | }); |
| 403 | return results; | 456 | return page; |
| 404 | }); | 457 | }); |
| 405 | 458 | ||
| 406 | /** What `tools.describe.tool()` calls inside the sandbox. */ | 459 | /** What `tools.describe.tool()` calls inside the sandbox. */ |