Skip to content

Commit ea054ea

Browse files
Merge pull request #76 from corbitsdev/cl-11952-scope-coordinator-and-writer-write_artifact-schemas-to-the
fix(artifacts): scope coordinator and writer write_artifact schemas to their kinds
2 parents a503829 + 5a035ec commit ea054ea

6 files changed

Lines changed: 323 additions & 36 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { expect, test } from "bun:test";
2+
import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities";
3+
import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows";
4+
import { createArtifactTools } from "./tools.ts";
5+
6+
const deployment = {
7+
source: {
8+
kind: "asset" as const,
9+
assetId: "asset",
10+
package: { format: "tarball" as const },
11+
},
12+
entry: "entry",
13+
pin: "@corbits-builder/writer-native@1.0.0",
14+
sourceOfferingIds: ["offering"],
15+
defaultSourceOfferingId: "offering",
16+
};
17+
18+
const sampleApproaches = {
19+
approaches: [
20+
{
21+
id: "simple",
22+
name: "Simple",
23+
summary: "Client-side forecast view.",
24+
bestFor: "Fast launch",
25+
pros: ["Fast to build"],
26+
cons: ["No server cache"],
27+
effort: "low" as const,
28+
risks: ["Rate limits"],
29+
},
30+
{
31+
id: "cached",
32+
name: "Cached",
33+
summary: "Server-cached forecast view.",
34+
bestFor: "Higher traffic",
35+
pros: ["Fewer upstream calls"],
36+
cons: ["More moving parts"],
37+
effort: "medium" as const,
38+
risks: ["Stale cache"],
39+
},
40+
],
41+
recommended: "simple",
42+
};
43+
44+
function harness(role: WorkflowRole) {
45+
const written: unknown[] = [];
46+
const fetch = async (path: string, init: RequestInit) => {
47+
if (path === "/api/workflow-project-scope")
48+
return Response.json({
49+
tenantId: "tenant",
50+
projectId: "sb-project1",
51+
domain: "example.invalid",
52+
role,
53+
workflows: {
54+
writer: deployment,
55+
reviewer: deployment,
56+
coder: deployment,
57+
designer: deployment,
58+
},
59+
openNetwork: false,
60+
build: {
61+
assetDirectory: "/tmp",
62+
browserCli: "/tmp/browser",
63+
childPath: "/tmp/child.ts",
64+
dependencyRoots: ["/tmp"],
65+
ripgrepPath: "/tmp/rg",
66+
runtimeVersion: "1.0.0",
67+
},
68+
});
69+
if (path.endsWith("/artifacts") && init.method === "POST") {
70+
const body = JSON.parse(String(init.body)) as { kind: string };
71+
written.push(body);
72+
return Response.json({
73+
artifact: {
74+
artifactId: "art-" + body.kind,
75+
kind: body.kind,
76+
version: written.length,
77+
sha256: "a".repeat(64),
78+
},
79+
});
80+
}
81+
return Response.json({ code: "unexpected_route" }, { status: 404 });
82+
};
83+
const capabilities = {
84+
resolve: () => ({ resolve: async () => ({ kind: "http", fetch }) }),
85+
} as unknown as RuntimeCapabilities;
86+
const instance = createArtifactTools(role)({ capabilities } as never);
87+
return {
88+
definitions: instance.definitions,
89+
written,
90+
write: (args: Record<string, unknown>) =>
91+
instance.run(
92+
{ id: "call", name: "write_artifact", arguments: args },
93+
new AbortController().signal,
94+
),
95+
};
96+
}
97+
98+
test("coordinator write_artifact exposes only problem_brief without approaches", async () => {
99+
const coordinator = harness("coordinator");
100+
const definition = coordinator.definitions.find(
101+
(item) => item.name === "write_artifact",
102+
);
103+
expect(definition).toBeDefined();
104+
expect(definition?.description).not.toContain("approaches");
105+
const properties = (
106+
definition?.inputSchema as
107+
| { properties?: Record<string, unknown> }
108+
| undefined
109+
)?.properties;
110+
expect(properties?.["kind"]).toEqual({ const: "problem_brief" });
111+
expect(properties?.["approaches"]).toBeUndefined();
112+
113+
const withApproaches = await coordinator.write({
114+
kind: "problem_brief",
115+
title: "Problem Brief",
116+
content: "# Problem Brief\n\nWeather at a glance.",
117+
approaches: sampleApproaches,
118+
});
119+
expect(withApproaches.isError).toBe(true);
120+
expect(withApproaches.content).toContain("approaches");
121+
122+
const wrongKind = await coordinator.write({
123+
kind: "constraints",
124+
title: "Constraints",
125+
content: "# Constraints",
126+
});
127+
expect(wrongKind.isError).toBe(true);
128+
expect(wrongKind.content).toBe(
129+
"brief_only: you write the problem brief; deploy_writer has a writer write the constraints",
130+
);
131+
132+
const valid = await coordinator.write({
133+
kind: "problem_brief",
134+
title: "Problem Brief",
135+
content: "# Problem Brief\n\nWeather at a glance.",
136+
});
137+
expect(valid.isError).toBeUndefined();
138+
expect(coordinator.written).toHaveLength(1);
139+
});
140+
141+
test("every role's artifact tool definitions declare top-level type: object", () => {
142+
const roles: WorkflowRole[] = [
143+
"coordinator",
144+
"writer",
145+
"reviewer",
146+
"coder",
147+
"designer",
148+
];
149+
for (const role of roles) {
150+
for (const definition of harness(role).definitions) {
151+
expect(
152+
(definition.inputSchema as { type?: unknown } | undefined)?.type,
153+
).toBe("object");
154+
}
155+
}
156+
});
157+
158+
test("writer write_artifact exposes only writer kinds and validates approaches", async () => {
159+
const writer = harness("writer");
160+
const definition = writer.definitions.find(
161+
(item) => item.name === "write_artifact",
162+
);
163+
expect(
164+
(definition?.inputSchema as { type?: unknown } | undefined)?.type,
165+
).toBe("object");
166+
const branches = (
167+
definition?.inputSchema as
168+
| {
169+
anyOf?: {
170+
additionalProperties?: boolean;
171+
properties?: {
172+
kind?: { const?: string };
173+
approaches?: unknown;
174+
};
175+
}[];
176+
}
177+
| undefined
178+
)?.anyOf;
179+
const branchFor = (kind: string) =>
180+
branches?.find((branch) => branch.properties?.kind?.const === kind);
181+
expect(branches).toHaveLength(4);
182+
const approaches = branchFor("approaches");
183+
expect(approaches?.additionalProperties).toBe(false);
184+
expect(Object.keys(approaches?.properties ?? {}).sort()).toEqual([
185+
"approaches",
186+
"content",
187+
"kind",
188+
"openQuestions",
189+
"title",
190+
]);
191+
for (const kind of ["constraints", "product_requirements", "build_plan"]) {
192+
const branch = branchFor(kind);
193+
expect(branch?.additionalProperties).toBe(false);
194+
expect(Object.keys(branch?.properties ?? {}).sort()).toEqual([
195+
"content",
196+
"kind",
197+
"openQuestions",
198+
"title",
199+
]);
200+
}
201+
expect(branchFor("problem_brief")).toBeUndefined();
202+
203+
const briefAttempt = await writer.write({
204+
kind: "problem_brief",
205+
title: "Problem Brief",
206+
content: "# Problem Brief",
207+
});
208+
expect(briefAttempt.isError).toBe(true);
209+
expect(briefAttempt.content).toStartWith("invalid_arguments:");
210+
211+
for (const kind of ["constraints", "product_requirements", "build_plan"]) {
212+
const rejected = await writer.write({
213+
kind,
214+
title: "Doc",
215+
content: "# Doc",
216+
approaches: sampleApproaches,
217+
});
218+
expect(rejected.isError).toBe(true);
219+
expect(rejected.content).toStartWith("invalid_arguments:");
220+
expect(rejected.content).toContain("approaches");
221+
}
222+
expect(writer.written).toHaveLength(0);
223+
224+
const validApproaches = await writer.write({
225+
kind: "approaches",
226+
title: "Approaches",
227+
content: "# Approaches",
228+
approaches: sampleApproaches,
229+
});
230+
expect(validApproaches.isError).toBeUndefined();
231+
expect(writer.written).toEqual([
232+
{
233+
kind: "approaches",
234+
title: "Approaches",
235+
content: "# Approaches",
236+
approaches: sampleApproaches,
237+
},
238+
]);
239+
});

‎apps/hub/src/artifacts/tools.ts‎

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery";
88
import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities";
99
import {
1010
ArtifactContent,
11-
DocumentWrite,
11+
BriefWrite,
1212
ArtifactList,
1313
ArtifactRead,
1414
ArtifactReceipt,
15+
WriterDocumentWrite,
1516
} from "@corbits-builder/schemas/artifact";
1617
import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows";
1718
import { unpackDesign } from "./design-tools.ts";
@@ -45,11 +46,20 @@ const readDefinition = {
4546
"See which project documents exist, or read one.\nWith no arguments it lists: each document's artifactId, kind, title, current version, sha256, approvedVersion, the version a person approved, and openQuestionCount, the questions its current version leaves open for the person; inFlight, the writer, reviewer, coder and designer runs still open, each with its status, deployed until its first assignment arrives and running after, and each working (mid-turn) or waiting (parked for your next mail), with progress once it has acted: its turns so far, its latest tool calls as tool and target, the files it has written, and lastActivityAt; failed, the latest runs that stopped on an error, with when and why, which deliver nothing more; awaitingApproval, the kinds of filed documents whose current version waits on the person's approval; and preferenceGuidance, how the person has said they want to work.\nWith a kind or an artifactId it returns that document's content at its current version, or at the given version, with the openQuestions that version leaves for the person; a long document comes in numbered parts, and part reads the next one.",
4647
inputSchema: { ...ToolRead.toJsonSchema() },
4748
};
48-
const writeDefinition = {
49+
const briefWriteDefinition = {
50+
name: "write_artifact",
51+
description:
52+
"Write the next version of the problem brief. The hub assigns its id, version and sha256 and returns them. Writing a version is not approval.\nopenQuestions lists each question in the document's Open questions section that the person must answer: a short id, the question as they would read it, whyItBlocks, and suggestedAnswers, your recommendation first. A version written without openQuestions leaves none open.",
53+
inputSchema: { ...BriefWrite.toJsonSchema() },
54+
};
55+
const writerWriteDefinition = {
4956
name: "write_artifact",
5057
description:
5158
"Write the next version of the project document of one kind. The hub assigns its id, version and sha256 and returns them. Writing a version is not approval.\nopenQuestions lists each question in the document's Open questions section that the person must answer: a short id, the question as they would read it, whyItBlocks, and suggestedAnswers, your recommendation first. A version written without openQuestions leaves none open.\nOn the approaches document, approaches carries the approaches it compares, for the person to see side by side: each with a short id, its name, a one-line summary, bestFor, its pros, cons and risks as short phrases, and its effort; recommended names the id of the one you recommend.",
52-
inputSchema: { ...DocumentWrite.toJsonSchema() },
59+
inputSchema: {
60+
type: "object" as const,
61+
...WriterDocumentWrite.toJsonSchema(),
62+
},
5363
};
5464

5565
export function failure(callId: string, error: unknown) {
@@ -67,17 +77,23 @@ export function failure(callId: string, error: unknown) {
6777
return projectScopeFailure(callId, error);
6878
}
6979

80+
const DEFINITIONS_BY_ROLE: Record<
81+
WorkflowRole,
82+
readonly (typeof readDefinition)[]
83+
> = {
84+
coordinator: [readDefinition, briefWriteDefinition],
85+
writer: [writerWriteDefinition],
86+
reviewer: [readDefinition],
87+
coder: [readDefinition],
88+
designer: [readDefinition],
89+
};
90+
7091
export function createArtifactTools(
7192
role: WorkflowRole,
7293
): AnnotatedToolFactory<ArtifactToolEnv> {
7394
// A writer's or reviewer's task carries every document it needs in full; a read tool sends
7495
// writers looking for documents that do not exist.
75-
const definitions =
76-
role === "coordinator"
77-
? [readDefinition, writeDefinition]
78-
: role === "writer"
79-
? [writeDefinition]
80-
: [readDefinition];
96+
const definitions = DEFINITIONS_BY_ROLE[role];
8197
return defineTool<ArtifactToolEnv>({
8298
id: "@corbits-builder/artifacts/tools",
8399
credentials: [{ handle: HUB_CREDENTIAL_HANDLE }],
@@ -120,19 +136,28 @@ export function createArtifactTools(
120136
signal,
121137
});
122138
if (call.name === "write_artifact") {
123-
const input = DocumentWrite(call.arguments);
124-
if (input instanceof type.errors)
139+
// The coordinator's writers own every kind but the brief, so the documents the person
140+
// decides on come from them.
141+
const rawKind = (call.arguments as { kind?: unknown } | undefined)
142+
?.kind;
143+
if (
144+
role === "coordinator" &&
145+
typeof rawKind === "string" &&
146+
rawKind !== "problem_brief"
147+
)
125148
return {
126149
callId: call.id,
127-
content: "invalid_arguments: " + input.summary,
150+
content: `brief_only: you write the problem brief; deploy_writer has a writer write the ${rawKind}`,
128151
isError: true,
129152
};
130-
// The coordinator's writers own every kind but the brief, so the documents the person
131-
// decides on come from them.
132-
if (role === "coordinator" && input.kind !== "problem_brief")
153+
const input =
154+
role === "coordinator"
155+
? BriefWrite(call.arguments)
156+
: WriterDocumentWrite(call.arguments);
157+
if (input instanceof type.errors)
133158
return {
134159
callId: call.id,
135-
content: `brief_only: you write the problem brief; deploy_writer has a writer write the ${input.kind}`,
160+
content: "invalid_arguments: " + input.summary,
136161
isError: true,
137162
};
138163
const body = JSON.stringify(input);

‎apps/hub/src/projects/errors.test.ts‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -103,22 +103,6 @@ test("a rejected write_artifact call reaches the agent with {path, message} issu
103103
return JSON.parse(String(result.content));
104104
};
105105

106-
expect(
107-
await write({
108-
kind: "problem_brief",
109-
approaches: { approaches: [approach("a"), approach("b")] },
110-
}),
111-
).toEqual({
112-
code: "invalid_artifact_write",
113-
status: 400,
114-
issues: [
115-
{
116-
path: "approaches",
117-
message:
118-
"goes on the approaches document; write this problem_brief without it",
119-
},
120-
],
121-
});
122106
expect(
123107
await write({
124108
kind: "approaches",

0 commit comments

Comments
 (0)