Skip to content

Commit 711802c

Browse files
authored
fix(codemode): let search be awaited or chained like a Promise (#59981)
1 parent 651032b commit 711802c

10 files changed

Lines changed: 112 additions & 12 deletions

File tree

‎packages/codemode/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,8 @@ every visible tool. Hosts render their own model-facing instructions from these
198198
and `CodeMode.toolExpression(path)` supply the exact callable forms.
199199
200200
The synchronous `search(...)` built-in is always available. It supports exact-path lookup, namespace-scoped search,
201-
empty-query browsing, and pagination, and returns callable paths with full signatures. Search counts toward
201+
empty-query browsing, and pagination, and returns callable paths with full signatures. Its result can be used directly,
202+
awaited, or chained with `then`, `catch`, and `finally`, so programs work whichever way they treat it. Search counts toward
202203
`maxToolCalls`. Search also matches descriptions from enclosing `Namespace` values.
203204
204205
## Execution Limits

‎packages/codemode/interpreter-support.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ the ultimate source of truth. Upstream test262 files run verbatim from `test/tes
3131
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Uint8Array values inside CodeMode.
3232
- [x] Tool calls through the host-provided `tools` tree only.
3333
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
34-
shadowable by program declarations like other globals.
34+
shadowable by program declarations like other globals. Its result can also be awaited or chained with
35+
`then`, `catch`, and `finally`.
3536
- [x] Cooperative timeout, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency.
3637
- [x] The timeout fires between interpreter steps, so one built-in is bounded in what it may build: strings up to
3738
2^24 characters (`repeat`, `pad*`, `concat`, `join`, `+`, template literals, `JSON.stringify`), arrays up to

‎packages/codemode/src/interpreter/globals.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,7 @@ type Factory = <R>(ctx: Interpreter<R>) => Value
8787
// A table rather than a list so the names are known before any runtime exists.
8888
const table: Record<string, Factory> = {
8989
tools: () => new ToolReference([]),
90-
search: (ctx) =>
91-
native(ctx.builtins, { name: "search", call: (_, args) => ctx.tool(ctx.tools.search, args), callback: false }),
90+
search: (ctx) => native(ctx.builtins, { name: "search", call: (_, args) => ctx.search(args), callback: false }),
9291
undefined: () => undefined,
9392
NaN: () => NaN,
9493
Infinity: () => Infinity,

‎packages/codemode/src/interpreter/interpreter.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ import {
118118
} from "./runner.js"
119119
import { describeValue, isOpaque, rejectCircularInsertion, typeofValue } from "./references.js"
120120
import { fn } from "./native.js"
121-
import { capability } from "./promises.js"
121+
import { capability, promiseLike } from "./promises.js"
122122
import { ScopeStack } from "./scope.js"
123123
import { constructRegExp } from "../stdlib/regexp.js"
124124
import { enumerableSource } from "../stdlib/object.js"
@@ -433,6 +433,11 @@ export class Interpreter<R> {
433433
for (const arg of args) json.push(yield* toBoundary(this, arg))
434434
return fromJson(this, (yield this.runner.blocking(run(json))) as Json | undefined)
435435
}
436+
437+
/** The built-in search: its result is usable directly, and can also be awaited or chained like a Promise. */
438+
*search(args: Array<Value>) {
439+
return promiseLike(this, yield* this.tool(this.tools.search, args))
440+
}
436441
}
437442

438443
/** One activation: the top-level program or a single function call, evaluating against its own scope chain. */

‎packages/codemode/src/interpreter/promises.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type AstNode, Throw, typeError } from "./model.js"
2-
import { Callable, define, get, hidden, Arr, Fn, PromiseObj, record, type Value } from "./objects.js"
2+
import { Callable, define, get, hidden, Arr, Fn, Obj, PromiseObj, record, type Value } from "./objects.js"
33
import { constructor, fn, methods, native, receiver, requiresNew } from "./native.js"
44
import { createAggregateErrorValue, materialize } from "./errors.js"
55
import { describeValue, typeofValue } from "./references.js"
@@ -258,6 +258,41 @@ function* constructPromise<R>(ctx: Interpreter<R>, executor: Value, site: AstNod
258258
return made.promise
259259
}
260260

261+
const promiseLikePrototypes = new WeakMap<object, Obj>()
262+
263+
/**
264+
* Lets a synchronous object result also be awaited or chained like a Promise. It inherits non-enumerable
265+
* then/catch/finally that settle with a plain copy, so data views never show them and awaiting does not recurse.
266+
*/
267+
export const promiseLike = <R>(ctx: Interpreter<R>, value: Value): Value => {
268+
if (!(value instanceof Obj) || value.proto !== ctx.builtins.Object) return value
269+
value.proto = promiseLikePrototype(ctx)
270+
return value
271+
}
272+
273+
const promiseLikePrototype = <R>(ctx: Interpreter<R>): Obj => {
274+
const cached = promiseLikePrototypes.get(ctx)
275+
if (cached !== undefined) return cached
276+
const proto = new Obj(ctx.builtins.Object)
277+
const settled = (thisValue: Value, name: string) => {
278+
const source = receiver(Obj, thisValue, name)
279+
const copy = new Obj(ctx.builtins.Object)
280+
for (const [key, slot] of source.props) copy.props.set(key, { ...slot })
281+
return ctx.runner.promiseResolve(copy)
282+
}
283+
methods(
284+
ctx.builtins,
285+
proto,
286+
(["then", "catch", "finally"] as const).map((name) => [
287+
name,
288+
name === "then" ? 2 : 1,
289+
(thisValue, args, site) => instanceMethod(ctx, name, settled(thisValue, name), args, site),
290+
]),
291+
)
292+
promiseLikePrototypes.set(ctx, proto)
293+
return proto
294+
}
295+
261296
export const promiseGlobal = <R>(ctx: Interpreter<R>) => {
262297
const builtins = ctx.builtins
263298
const proto = builtins.Promise

‎packages/codemode/test/errors.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,11 @@ describe("source locations", () => {
151151
test("names a missing method on a call instead of the previous line", async () => {
152152
const failure = await error(`// Try search with different namespaces
153153
for (const ns of ["github", "tools.github", "tools", ""]) {
154-
const s = await search({query: "star", namespace: ns, limit: 100}).catch(e=>({items:[],error:String(e)}));
154+
const s = await search({query: "star", namespace: ns, limit: 100}).filter((item) => item.path);
155155
return s
156156
}`)
157157
expect(failure.location).toEqual({ line: 3, column: 19 })
158-
expect(failure.message).toBe("TypeError: search(...).catch is not a function. (line 3, col 19)")
158+
expect(failure.message).toBe("TypeError: search(...).filter is not a function. (line 3, col 19)")
159159
})
160160
})
161161

‎packages/codemode/test/parity.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -997,8 +997,8 @@ describe("coercion parity: unknown static members read as undefined", () => {
997997
expect(await value(`try { JSON.rawJSON("1") } catch (e) { return e.message }`)).toBe(
998998
"JSON.rawJSON is not a function.",
999999
)
1000-
expect(await value(`try { search({ query: "star" }).catch(() => 1) } catch (e) { return e.message }`)).toBe(
1001-
"search(...).catch is not a function.",
1000+
expect(await value(`try { search({ query: "star" }).filter(() => 1) } catch (e) { return e.message }`)).toBe(
1001+
"search(...).filter is not a function.",
10021002
)
10031003
expect(
10041004
await value(`const foo = () => ({ bar: () => ({}) }); try { foo().bar().baz() } catch (e) { return e.message }`),

‎packages/codemode/test/tool-paths.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,65 @@ describe("tools.search alias", () => {
355355
})
356356
})
357357

358+
describe("search results used like a Promise", () => {
359+
const runtime = CodeMode.make({ tools: { api: { list: echo("List things", "listed") } } })
360+
361+
test("search(...) can be read, awaited, or chained", async () => {
362+
const call = "search"
363+
expect(
364+
await value(
365+
runtime,
366+
`
367+
const query = { query: "list" }
368+
return [
369+
${call}(query).items[0].path,
370+
(await ${call}(query)).items[0].path,
371+
await ${call}(query).then((result) => result.items[0].path),
372+
(await ${call}(query).catch(() => "unused")).items[0].path,
373+
(await ${call}(query).finally(() => "ignored")).items[0].path,
374+
(await Promise.all([${call}(query), ${call}({})])).map((result) => result.items.length),
375+
]
376+
`,
377+
),
378+
).toStrictEqual(["tools.api.list", "tools.api.list", "tools.api.list", "tools.api.list", "tools.api.list", [1, 1]])
379+
})
380+
381+
test("the Promise methods stay out of data views", async () => {
382+
const expected = { items: [{ path: "tools.api.list" }], remaining: 0, next: null }
383+
const result = await value(
384+
runtime,
385+
`
386+
const result = search({ query: "list" })
387+
const forIn = []
388+
for (const key in result) forIn.push(key)
389+
return {
390+
json: JSON.parse(JSON.stringify(result)),
391+
keys: Object.keys(result),
392+
spread: Object.keys({ ...result }),
393+
forIn,
394+
returned: result,
395+
}
396+
`,
397+
)
398+
expect(result).toMatchObject({
399+
json: expected,
400+
keys: ["items", "remaining", "next"],
401+
spread: ["items", "remaining", "next"],
402+
forIn: ["items", "remaining", "next"],
403+
returned: expected,
404+
})
405+
})
406+
407+
test("invalid input throws at the call, so try/catch sees it in either style", async () => {
408+
expect((await failure(runtime, `return search({ limit: "ten" }).items`)).message).toContain(
409+
"Invalid input for tool 'search'",
410+
)
411+
expect(
412+
await value(runtime, `try { await search({ limit: "ten" }) } catch (error) { return error.message }`),
413+
).toContain("Invalid input for tool 'search'")
414+
})
415+
})
416+
358417
describe("tool references under ==", () => {
359418
test("compare by identity against data objects without converting them", async () => {
360419
const runtime = CodeMode.make({ tools: { probe: echo("Probe", "ok") } })

‎packages/core/src/codemode/instructions.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const prompt = (hasMoreTools: boolean) => `# Code Mode
1010
1111
Use the \`execute\` tool to call the tools listed below. They cannot be called directly${hasMoreTools ? ", and neither can \`search\`. Both" : ". They"} only work inside code you pass to \`execute\`.
1212
13-
${hasMoreTools ? `The catalog is partial. Inside \`execute\`, use \`search(...)\` to find a tool, then call it by the \`path\` in the result. \`search\` is synchronous. Call it without \`await\`; it does not return a Promise. Do not guess tool names.
13+
${hasMoreTools ? `The catalog is partial. Inside \`execute\`, use \`search(...)\` to find a tool, then call it by the \`path\` in the result. Do not guess tool names.
1414
1515
- ${searchSignature}` : "The catalog is complete. Do not guess tool names."}
1616

‎packages/core/test/codemode/catalog.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ describe("CodeModeInstructions.render", () => {
131131
expect(partial).toContain("- orders (1 tool, none shown)")
132132
expect(partial).toContain("They cannot be called directly, and neither can `search`.")
133133
expect(partial).toContain(
134-
"The catalog is partial. Inside `execute`, use `search(...)` to find a tool, then call it by the `path` in the result. `search` is synchronous. Call it without `await`; it does not return a Promise.",
134+
"The catalog is partial. Inside `execute`, use `search(...)` to find a tool, then call it by the `path` in the result.",
135135
)
136136
expect(partial).toContain("- search({")
137137
expect(partial).toContain(" /** @integer @exclusiveMinimum 0 */\n limit?: number,")

0 commit comments

Comments
 (0)