Skip to content

Commit b99a7e5

Browse files
committed
fix(artifacts): scope coordinator and writer write_artifact schemas to their kinds
1 parent 66cf972 commit b99a7e5

6 files changed

Lines changed: 239 additions & 22 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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("writer write_artifact exposes only writer kinds and validates approaches", async () => {
142+
const writer = harness("writer");
143+
const definition = writer.definitions.find(
144+
(item) => item.name === "write_artifact",
145+
);
146+
const properties = (
147+
definition?.inputSchema as
148+
| {
149+
properties?: { kind?: { enum?: string[] }; approaches?: unknown };
150+
}
151+
| undefined
152+
)?.properties;
153+
expect(properties?.kind?.enum).toEqual([
154+
"approaches",
155+
"build_plan",
156+
"constraints",
157+
"product_requirements",
158+
]);
159+
expect(properties?.approaches).toBeDefined();
160+
161+
const briefAttempt = await writer.write({
162+
kind: "problem_brief",
163+
title: "Problem Brief",
164+
content: "# Problem Brief",
165+
});
166+
expect(briefAttempt.isError).toBe(true);
167+
expect(briefAttempt.content).toStartWith("invalid_arguments:");
168+
169+
const validApproaches = await writer.write({
170+
kind: "approaches",
171+
title: "Approaches",
172+
content: "# Approaches",
173+
approaches: sampleApproaches,
174+
});
175+
expect(validApproaches.isError).toBeUndefined();
176+
expect(writer.written).toHaveLength(1);
177+
});

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

Lines changed: 38 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,17 @@ 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: { ...WriterDocumentWrite.toJsonSchema() },
5360
};
5461

5562
export function failure(callId: string, error: unknown) {
@@ -67,17 +74,23 @@ export function failure(callId: string, error: unknown) {
6774
return projectScopeFailure(callId, error);
6875
}
6976

77+
const DEFINITIONS_BY_ROLE: Record<
78+
WorkflowRole,
79+
readonly (typeof readDefinition)[]
80+
> = {
81+
coordinator: [readDefinition, briefWriteDefinition],
82+
writer: [writerWriteDefinition],
83+
reviewer: [readDefinition],
84+
coder: [readDefinition],
85+
designer: [readDefinition],
86+
};
87+
7088
export function createArtifactTools(
7189
role: WorkflowRole,
7290
): AnnotatedToolFactory<ArtifactToolEnv> {
7391
// A writer's or reviewer's task carries every document it needs in full; a read tool sends
7492
// writers looking for documents that do not exist.
75-
const definitions =
76-
role === "coordinator"
77-
? [readDefinition, writeDefinition]
78-
: role === "writer"
79-
? [writeDefinition]
80-
: [readDefinition];
93+
const definitions = DEFINITIONS_BY_ROLE[role];
8194
return defineTool<ArtifactToolEnv>({
8295
id: "@corbits-builder/artifacts/tools",
8396
credentials: [{ handle: HUB_CREDENTIAL_HANDLE }],
@@ -120,19 +133,28 @@ export function createArtifactTools(
120133
signal,
121134
});
122135
if (call.name === "write_artifact") {
123-
const input = DocumentWrite(call.arguments);
124-
if (input instanceof type.errors)
136+
// The coordinator's writers own every kind but the brief, so the documents the person
137+
// decides on come from them.
138+
const rawKind = (call.arguments as { kind?: unknown } | undefined)
139+
?.kind;
140+
if (
141+
role === "coordinator" &&
142+
typeof rawKind === "string" &&
143+
rawKind !== "problem_brief"
144+
)
125145
return {
126146
callId: call.id,
127-
content: "invalid_arguments: " + input.summary,
147+
content: `brief_only: you write the problem brief; deploy_writer has a writer write the ${rawKind}`,
128148
isError: true,
129149
};
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")
150+
const input =
151+
role === "coordinator"
152+
? BriefWrite(call.arguments)
153+
: WriterDocumentWrite(call.arguments);
154+
if (input instanceof type.errors)
133155
return {
134156
callId: call.id,
135-
content: `brief_only: you write the problem brief; deploy_writer has a writer write the ${input.kind}`,
157+
content: "invalid_arguments: " + input.summary,
136158
isError: true,
137159
};
138160
const body = JSON.stringify(input);

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ test("a rejected write_artifact call reaches the agent with {path, message} issu
105105

106106
expect(
107107
await write({
108-
kind: "problem_brief",
108+
kind: "constraints",
109109
approaches: { approaches: [approach("a"), approach("b")] },
110110
}),
111111
).toEqual({
@@ -115,7 +115,7 @@ test("a rejected write_artifact call reaches the agent with {path, message} issu
115115
{
116116
path: "approaches",
117117
message:
118-
"goes on the approaches document; write this problem_brief without it",
118+
"goes on the approaches document; write this constraints without it",
119119
},
120120
],
121121
});

‎packages/schemas/src/artifact.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ const sha256 = /^[a-f0-9]{64}$/;
1010
// Documents a model writes as text. A design concept's option is filed from the designer's
1111
// workspace by file_design_option, the design by the person's choice among the options, and the
1212
// app from the coder's workspace each time check-app passes.
13-
const DocumentKind = type(
14-
"'problem_brief' | 'constraints' | 'approaches' | 'product_requirements' | 'build_plan'",
13+
const WriterDocumentKind = type(
14+
"'constraints' | 'approaches' | 'product_requirements' | 'build_plan'",
1515
);
16+
const DocumentKind = WriterDocumentKind.or("'problem_brief'");
1617
const WritableArtifactKind = DocumentKind.or(
1718
"'design' | 'design_option' | 'app'",
1819
);
@@ -32,6 +33,23 @@ export const ArtifactKindWrite = type({
3233
"approaches?": ApproachesMeta,
3334
});
3435
export type ArtifactKindWrite = typeof ArtifactKindWrite.infer;
36+
export const BriefWrite = type({
37+
"+": "reject",
38+
kind: "'problem_brief'",
39+
title: text,
40+
content,
41+
"openQuestions?": OpenQuestions,
42+
});
43+
export type BriefWrite = typeof BriefWrite.infer;
44+
export const WriterDocumentWrite = type({
45+
"+": "reject",
46+
kind: WriterDocumentKind,
47+
title: text,
48+
content,
49+
"openQuestions?": OpenQuestions,
50+
"approaches?": ApproachesMeta,
51+
});
52+
export type WriterDocumentWrite = typeof WriterDocumentWrite.infer;
3553
export const DocumentWrite = type({
3654
"+": "reject",
3755
kind: DocumentKind,

‎workflows/coordinator/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@corbits-builder/coordinator",
3-
"version": "0.35.0",
3+
"version": "0.35.1",
44
"private": true,
55
"license": "SEE LICENSE IN ../../LICENSE.md",
66
"author": "Sawyer Cutler <sawyer@dirtroad.dev>",

‎workflows/writer/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@corbits-builder/writer",
3-
"version": "0.7.0",
3+
"version": "0.7.1",
44
"private": true,
55
"license": "SEE LICENSE IN ../../LICENSE.md",
66
"author": "Sawyer Cutler <sawyer@dirtroad.dev>",

0 commit comments

Comments
 (0)