Skip to content

Commit 30cfb4d

Browse files
authored
Serve runtime-observed output shapes for schemaless tools (#1759)
* Serve runtime-observed output shapes for schemaless tools * Mark observed types inline in the rendered signature
1 parent 2c75d7a commit 30cfb4d

12 files changed

Lines changed: 778 additions & 28 deletions

File tree

apps/desktop/build/entitlements.mac.plist

Lines changed: 0 additions & 21 deletions
This file was deleted.

apps/desktop/build/icon.png

-63.1 KB
Binary file not shown.

e2e/scenarios/shape-memory.test.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Cross-target: muscle memory — runtime-observed output shapes. Most OpenAPI
2+
// operations declare no response schema, so `tools.describe.tool()` used to
3+
// render `data: unknown` forever and the model had to guess response shapes.
4+
// This journey proves the warm path end to end through public surfaces only:
5+
// a schemaless tool describes as `unknown`, one real invocation against a live
6+
// upstream teaches the shape, and the very next describe serves a real
7+
// TypeScript type marked as observed.
8+
import { randomBytes } from "node:crypto";
9+
import { createServer } from "node:http";
10+
11+
import { expect } from "@effect/vitest";
12+
import { Effect } from "effect";
13+
import { composePluginApi } from "@executor-js/api/server";
14+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
15+
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
16+
17+
import { scenario } from "../src/scenario";
18+
import { Api, Target } from "../src/services";
19+
20+
const api = composePluginApi([openApiHttpPlugin()] as const);
21+
22+
/** One GET operation whose 200 declares no response schema — the shape the
23+
* model would otherwise have to guess. */
24+
const issuesSpec = JSON.stringify({
25+
openapi: "3.0.3",
26+
info: { title: "Issues API", version: "1.0.0" },
27+
paths: {
28+
"/issues": {
29+
get: {
30+
operationId: "listIssues",
31+
summary: "List issues",
32+
responses: { "200": { description: "issues" } },
33+
},
34+
},
35+
},
36+
});
37+
38+
/** A live upstream for the single invocation that teaches the shape. */
39+
const serveIssuesFixture = Effect.acquireRelease(
40+
Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
41+
const server = createServer((_request, response) => {
42+
response.writeHead(200, { "content-type": "application/json" });
43+
response.end(
44+
JSON.stringify({
45+
issues: [
46+
{ id: 1, title: "first", open: true },
47+
{ id: 2, title: "second", open: false },
48+
],
49+
total: 2,
50+
}),
51+
);
52+
});
53+
server.listen(0, "127.0.0.1", () => {
54+
const addressInfo = server.address();
55+
const port = typeof addressInfo === "object" && addressInfo !== null ? addressInfo.port : 0;
56+
resume(
57+
Effect.succeed({
58+
url: `http://127.0.0.1:${port}`,
59+
close: () => {
60+
server.close();
61+
server.closeAllConnections();
62+
},
63+
}),
64+
);
65+
});
66+
}),
67+
(fixture) => Effect.sync(fixture.close),
68+
);
69+
70+
const describeCode = (slug: string) => `
71+
const details = await tools.describe.tool({ path: "${slug}.org.main.issues.listIssues" });
72+
return {
73+
outputTypeScript: details.outputTypeScript ?? null,
74+
note: details.outputTypeScriptNote ?? null,
75+
error: details.error ?? null,
76+
};
77+
`;
78+
79+
type DescribeOutcome = {
80+
readonly outputTypeScript: string | null;
81+
readonly note: string | null;
82+
readonly error: unknown;
83+
};
84+
85+
scenario(
86+
"Muscle memory · a schemaless tool's observed output shape reaches describe",
87+
{},
88+
Effect.scoped(
89+
Effect.gen(function* () {
90+
const target = yield* Target;
91+
const { client: makeApiClient } = yield* Api;
92+
const identity = yield* target.newIdentity();
93+
const client = yield* makeApiClient(api, identity);
94+
const slug = IntegrationSlug.make(`shape_memory_${randomBytes(4).toString("hex")}`);
95+
const upstream = yield* serveIssuesFixture;
96+
97+
yield* Effect.ensuring(
98+
Effect.gen(function* () {
99+
yield* client.openapi.addSpec({
100+
payload: {
101+
spec: { kind: "blob", value: issuesSpec },
102+
slug,
103+
baseUrl: upstream.url,
104+
authenticationTemplate: [
105+
{
106+
slug: "apiKey",
107+
type: "apiKey",
108+
headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
109+
},
110+
],
111+
},
112+
});
113+
yield* client.connections.create({
114+
payload: {
115+
owner: "org",
116+
name: ConnectionName.make("main"),
117+
integration: slug,
118+
template: AuthTemplateSlug.make("apiKey"),
119+
value: `key_${randomBytes(8).toString("hex")}`,
120+
},
121+
});
122+
123+
const describe = Effect.gen(function* () {
124+
const executed = yield* client.executions.execute({
125+
payload: { code: describeCode(String(slug)), autoApprove: true },
126+
});
127+
expect(executed.status, executed.text).toBe("completed");
128+
return JSON.parse(executed.text) as DescribeOutcome;
129+
});
130+
131+
// Cold: no declared response schema — the model sees unknown.
132+
const cold = yield* describe;
133+
expect(cold.error, "the tool resolves").toBeNull();
134+
expect(cold.outputTypeScript, "cold describe has no shape").toContain("data: unknown;");
135+
expect(cold.note, "cold describe carries no provenance note").toBeNull();
136+
137+
// One real call against the live upstream teaches the shape.
138+
const invoked = yield* client.executions.execute({
139+
payload: {
140+
code: `
141+
const result = await tools.${slug}.org.main.issues.listIssues({});
142+
return { ok: result.ok };
143+
`,
144+
autoApprove: true,
145+
},
146+
});
147+
expect(invoked.status, invoked.text).toBe("completed");
148+
expect(JSON.parse(invoked.text), "the teaching call succeeded").toEqual({ ok: true });
149+
150+
// Warm: the observed shape is served, marked as observed.
151+
const warm = yield* describe;
152+
expect(warm.outputTypeScript, "warm describe serves the observed shape").toContain(
153+
"issues",
154+
);
155+
expect(warm.outputTypeScript, "field types come from the live payload").toContain(
156+
"total",
157+
);
158+
expect(warm.outputTypeScript, "the shape no longer collapses").not.toContain(
159+
"data: unknown;",
160+
);
161+
expect(warm.note, "provenance is explicit").toContain("observed from 1 live response");
162+
}),
163+
Effect.gen(function* () {
164+
yield* client.connections
165+
.remove({
166+
params: {
167+
owner: "org",
168+
integration: slug,
169+
name: ConnectionName.make("main"),
170+
},
171+
})
172+
.pipe(Effect.ignore);
173+
yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
174+
}),
175+
);
176+
}),
177+
),
178+
);

packages/core/execution/src/skills.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const EXECUTE_SKILL_BODY = [
5858
"- The `tools` object is a lazy proxy — enumerating it (`Object.keys(tools)`, spread, `for...in`) throws. Use `tools.search()` or `tools.executor.coreTools.connections.list({})` instead.",
5959
'- Pass an object to system tools, e.g. `tools.search({ query: "..." })`, `tools.executor.coreTools.connections.list({})`, and `tools.describe.tool({ path })`.',
6060
'- `tools.describe.tool()` returns compact TypeScript shapes. Use `inputTypeScript`, `outputTypeScript`, and `typeScriptDefinitions`. If the path doesn\'t resolve, the result carries `error: { code: "tool_not_found", suggestions }` — use a suggestion instead of retrying the same path.',
61+
"- When `outputTypeScriptNote` is present, the `data` type was observed from live responses rather than declared by the provider: the listed fields are reliable, but the shape may be incomplete — prefer optional access for anything not listed.",
6162
"- For tools that return large collections (e.g. `getStates`, `getAll`), filter results in code rather than calling per-item tools.",
6263
"- Do not use `fetch` — all API calls go through `tools.*`.",
6364
"- If execution pauses for interaction, resume it with the returned `resumePayload`.",

packages/core/execution/src/tool-invoker.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -877,6 +877,33 @@ describe("tool discovery", () => {
877877
}),
878878
);
879879

880+
it.effect("serves an observed shape with a provenance note once a schemaless tool runs", () =>
881+
Effect.gen(function* () {
882+
const executor = yield* makeSearchExecutor();
883+
const invoker = makeExecutorToolInvoker(executor, {
884+
invokeOptions: { onElicitation: acceptAll },
885+
});
886+
887+
// Cold: no declared output schema — data renders as unknown, no note.
888+
const cold = yield* describeTool(executor, "github.org.main.listRepositoryIssues");
889+
expect(cold.outputTypeScript).toContain("data: unknown;");
890+
expect(cold.outputTypeScriptNote).toBeUndefined();
891+
892+
yield* invoker.invoke({
893+
path: "github.org.main.listRepositoryIssues",
894+
args: { owner: "executor", repo: "executor" },
895+
});
896+
897+
// Warm: the live `[]` payload becomes the served type, marked observed
898+
// both inline and via the note.
899+
const warm = yield* describeTool(executor, "github.org.main.listRepositoryIssues");
900+
expect(warm.outputTypeScript).toBe(
901+
"{ ok: true; data: unknown[] /* observed; may be incomplete */; http?: ToolHttpMeta } | { ok: false; error: ToolError }",
902+
);
903+
expect(warm.outputTypeScriptNote).toContain("observed from 1 live response");
904+
}),
905+
);
906+
880907
it.effect("describes a return type that accepts the sandbox invocation result", () =>
881908
Effect.gen(function* () {
882909
const executor = yield* makeSearchExecutor();

packages/core/execution/src/tool-invoker.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,12 @@ const TOOL_HTTP_META_TYPESCRIPT = "{ status: number; headers: { [k: string]: str
3131
const TOOL_FILE_TYPESCRIPT =
3232
'{ _tag: "ToolFile"; name?: string; mimeType: string; encoding: "base64"; data: string; byteLength: number; }';
3333

34-
const wrapOutputTypeScript = (outputTypeScript?: string): string =>
35-
`{ ok: true; data: ${outputTypeScript ?? "unknown"}; http?: ToolHttpMeta } | { ok: false; error: ToolError }`;
34+
const wrapOutputTypeScript = (outputTypeScript?: string, marker?: string): string =>
35+
`{ ok: true; data: ${outputTypeScript ?? "unknown"}${marker ?? ""}; http?: ToolHttpMeta } | { ok: false; error: ToolError }`;
36+
37+
/** Inline provenance for observed types — a model that copies only the type
38+
* string still sees the hint, since the compact render drops descriptions. */
39+
const OBSERVED_TYPE_MARKER = " /* observed; may be incomplete */";
3640

3741
const withToolResultDefinitions = (
3842
definitions?: Record<string, string>,
@@ -76,6 +80,7 @@ type DescribedTool = {
7680
readonly description?: string;
7781
readonly inputTypeScript?: string;
7882
readonly outputTypeScript?: string;
83+
readonly outputTypeScriptNote?: string;
7984
readonly typeScriptDefinitions?: Record<string, string>;
8085
/** Set when the path resolves to no tool — mirrors invoke's tool_not_found. */
8186
readonly error?: {
@@ -865,7 +870,18 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* (
865870
name: schema.name ?? path,
866871
description: schema.description,
867872
inputTypeScript: schema.inputTypeScript,
868-
outputTypeScript: wrapOutputTypeScript(schema.outputTypeScript),
873+
outputTypeScript: wrapOutputTypeScript(
874+
schema.outputTypeScript,
875+
schema.outputSchemaSource === "observed" ? OBSERVED_TYPE_MARKER : undefined,
876+
),
877+
// The compact TS render drops the schema's provenance description, so an
878+
// observed (runtime-inferred) shape gets an explicit note: the model
879+
// should treat the fields as reliable but not exhaustive.
880+
...(schema.outputSchemaSource === "observed"
881+
? {
882+
outputTypeScriptNote: `data type observed from ${schema.outputSchemaObservations ?? 1} live response(s), not declared by the provider; fields may be incomplete.`,
883+
}
884+
: {}),
869885
typeScriptDefinitions: withToolResultDefinitions(schema.typeScriptDefinitions),
870886
};
871887
return described;

packages/core/sdk/src/executor.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,3 +673,60 @@ describe("createExecutor", () => {
673673
}),
674674
);
675675
});
676+
677+
describe("muscle memory (observed output shapes)", () => {
678+
const provisioned = Effect.fn(function* () {
679+
const executor = yield* makeTestExecutor({
680+
plugins: [demoPlugin] as const,
681+
coreTools: { webBaseUrl: "http://localhost:3000" },
682+
});
683+
yield* executor.demo.seed();
684+
yield* executor.execute(ToolAddress.make("executor.coreTools.connections.create"), {
685+
owner: "org",
686+
name: String(CONN),
687+
integration: String(INTEG),
688+
template: String(TEMPLATE),
689+
identityLabel: "Demo",
690+
from: { provider: "memory", id: "secret-token" },
691+
});
692+
return executor;
693+
});
694+
695+
it.effect("serves an observed output shape once a schemaless tool has run", () =>
696+
Effect.gen(function* () {
697+
const executor = yield* provisioned();
698+
699+
// Cold: `run` declares no output schema, nothing observed yet.
700+
const cold = yield* executor.tools.schema(addr("run"));
701+
expect(cold?.outputSchema).toBeUndefined();
702+
expect(cold?.outputTypeScript).toBeUndefined();
703+
704+
yield* executor.execute(addr("run"), {});
705+
706+
// Warm: the live payload `{ ran: "run" }` becomes the served shape,
707+
// with provenance marked on the schema.
708+
const warm = yield* executor.tools.schema(addr("run"));
709+
expect(warm?.outputSchema).toMatchObject({
710+
type: "object",
711+
properties: { ran: { type: "string" } },
712+
required: ["ran"],
713+
description: "Observed from 1 live response; fields may be incomplete.",
714+
});
715+
expect(warm?.outputTypeScript).toContain("ran");
716+
expect(warm?.outputTypeScript).not.toBe("unknown");
717+
}),
718+
);
719+
720+
it.effect("never overrides a declared output schema with observations", () =>
721+
Effect.gen(function* () {
722+
const executor = yield* provisioned();
723+
724+
// `inspect` declares `outputSchema: { $ref: "#/$defs/Owner" }`; running
725+
// it observes `{ ran: "inspect" }`, which must not displace the
726+
// declared schema.
727+
yield* executor.execute(addr("inspect"), { pet: { lives: 9 } });
728+
const schema = yield* executor.tools.schema(addr("inspect"));
729+
expect(schema?.outputSchema).toEqual({ $ref: "#/$defs/Owner" });
730+
}),
731+
);
732+
});

0 commit comments

Comments
 (0)