diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c4aa0d7..129981be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -143,11 +143,12 @@ change follows, whether a person or a coding agent writes it. Maintainers bump `version` in the root, `apps/hub`, `apps/web`, `packages/client`, `packages/schemas`, `packages/tools-artifacts`, -`packages/tools-design` and `packages/tools-hub` package.json files, run -`bun install`, and merge. Once the publish workflow for that commit on main has -finished, `git tag -s vX.Y.Z && git push origin vX.Y.Z` retags its hub and -sidecar images as `X.Y.Z` and publishes the GitHub release; a 0.x version or -one with a `-` suffix is a pre-release tagged `rc`, any other is `latest`. +`packages/tools-design`, `packages/tools-handoff` and `packages/tools-hub` +package.json files, run `bun install`, and merge. Once the publish workflow for +that commit on main has finished, `git tag -s vX.Y.Z && git push origin vX.Y.Z` +retags its hub and sidecar images as `X.Y.Z` and publishes the GitHub release; a +0.x version or one with a `-` suffix is a pre-release tagged `rc`, any other is +`latest`. ## Security diff --git a/apps/hub/package.json b/apps/hub/package.json index ad46722a..1d9c440e 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -22,6 +22,7 @@ "@corbits-builder/schemas": "workspace:*", "@corbits-builder/tools-artifacts": "workspace:*", "@corbits-builder/tools-design": "workspace:*", + "@corbits-builder/tools-handoff": "workspace:*", "@corbits-builder/tools-hub": "workspace:*", "@corbits-builder/writer": "workspace:*", "@corbits/agent-token": "0.2.2", diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index 143be1b5..c30595e6 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -129,10 +129,11 @@ import { import { designChosenNote } from "../config/design.ts"; import { loadSettings } from "../settings/policy.ts"; import { + agentDecidesApprovals, preferenceGuidance, resolvePreferences, } from "../config/preferences.ts"; -import { awaitingApproval } from "../projects/deploy-tools.ts"; +import { awaitingApproval } from "@corbits-builder/tools-handoff"; import { frozenModelChoice, workflowBindings } from "../config/models.ts"; import { materialsNote } from "../config/materials.ts"; import { servingOffering } from "../workflows/install-for-tenant.ts"; @@ -733,9 +734,10 @@ export function createArtifactService( artifacts, inFlight, failed, - awaitingApproval: awaitingApproval(artifacts, preferences).map( - (item) => item.kind, - ), + awaitingApproval: awaitingApproval( + artifacts, + agentDecidesApprovals(preferences), + ).map((item) => item.kind), preferences, preferenceGuidance: preferenceGuidance(preferences, "coordinator"), }; diff --git a/apps/hub/src/auth/coordinator-run-grants.test.ts b/apps/hub/src/auth/coordinator-run-grants.test.ts index 9e7d060d..60674feb 100644 --- a/apps/hub/src/auth/coordinator-run-grants.test.ts +++ b/apps/hub/src/auth/coordinator-run-grants.test.ts @@ -4,7 +4,7 @@ import { evaluateMailAdmission, mailAcceptResource, } from "@intx/authz"; -import { DEPLOY_TOOLS } from "../projects/deploy-tools.ts"; +import { DEPLOY_TOOLS } from "@corbits-builder/tools-handoff/sidecar-bundle"; import { planCoordinatorSpecialistGrants, type CoordinatorRunGrant, diff --git a/apps/hub/src/projects/coordinator-resume.ts b/apps/hub/src/projects/coordinator-resume.ts index 6cc4ad79..9a3c47ec 100644 --- a/apps/hub/src/projects/coordinator-resume.ts +++ b/apps/hub/src/projects/coordinator-resume.ts @@ -8,7 +8,8 @@ import { resumeContext, resumeMessage } from "../config/resume.ts"; import { projectCoordinator } from "../db/schema.ts"; import { readDurableConversationTurns } from "../mail/conversation-turns.ts"; import type { CoordinatorRepoDirSource } from "../mail/coordinator-question.ts"; -import { awaitingApproval } from "./deploy-tools.ts"; +import { awaitingApproval } from "@corbits-builder/tools-handoff"; +import { agentDecidesApprovals } from "../config/preferences.ts"; import { getLogger } from "@intx/log"; import { requestErrorMetadata } from "../request-error.ts"; import { readJson } from "../lib/json.ts"; @@ -87,7 +88,7 @@ export function createCoordinatorResumeContext(deps: { ); return resumeContext({ turns, - awaiting: awaitingApproval(filed, preferences).map( + awaiting: awaitingApproval(filed, agentDecidesApprovals(preferences)).map( (artifact) => artifact.title, ), withApp: filed.some((artifact) => artifact.kind === "app"), diff --git a/apps/hub/src/projects/deploy-tools.test.ts b/apps/hub/src/projects/deploy-tools.test.ts index 8ef18209..bea2fa05 100644 --- a/apps/hub/src/projects/deploy-tools.test.ts +++ b/apps/hub/src/projects/deploy-tools.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; -import { createDeployTools } from "./deploy-tools.ts"; +import { createDeployTools } from "@corbits-builder/tools-handoff/sidecar-bundle"; +import { handoffConfig } from "./handoff-config.ts"; const deployment = { source: { @@ -61,7 +62,7 @@ function coderSpawn(changed: Record) { ? { resolve: async () => ({ kind: "http", fetch }) } : { send: async () => undefined }, } as unknown as RuntimeCapabilities; - const tools = createDeployTools({ + const tools = createDeployTools(handoffConfig, { tenantId: "tenant", projectId: "project", domain: "example.invalid", @@ -163,7 +164,7 @@ test("design_app starts one designer per concept within the person's limit, each }, }, } as unknown as RuntimeCapabilities; - const tools = createDeployTools({ + const tools = createDeployTools(handoffConfig, { tenantId: "tenant", projectId: "project", domain: "example.invalid", @@ -270,7 +271,7 @@ function writerTools(preferences: Record = {}) { ? { resolve: async () => ({ kind: "http", fetch }) } : { send: async () => undefined }, } as unknown as RuntimeCapabilities; - const tools = createDeployTools({ + const tools = createDeployTools(handoffConfig, { tenantId: "tenant", projectId: "project", domain: "example.invalid", diff --git a/apps/hub/src/projects/deploy-tools.ts b/apps/hub/src/projects/deploy-tools.ts deleted file mode 100644 index 5bfb12bf..00000000 --- a/apps/hub/src/projects/deploy-tools.ts +++ /dev/null @@ -1,735 +0,0 @@ -import { projectScopeFailure } from "../workflows/project-scope.ts"; -import { - resolveWorkflowProjectScope, - workflowHttpJson, - WorkflowHttpError, -} from "@corbits-builder/tools-hub"; -import { - defineTool, - type BaseEnv, - type AnnotatedToolFactory, -} from "@intx/agent/authoring"; -import { WorkflowDeploymentResponse } from "@intx/types"; -import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; -import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; -import { deriveRunAddress } from "@intx/workflow-deploy"; -import { type, TraversalError } from "arktype"; -import { - NativeDocumentKind, - NativeSpecialistAssignment, - NativeSpecialistDeployment, -} from "@corbits-builder/schemas/native-specialist"; -import { SourceRef } from "@corbits-builder/schemas"; -import { - ArtifactContent, - DesignBundle, - DesignConcept, - MATERIAL_KIND, - type ArtifactRef, -} from "@corbits-builder/schemas/artifact"; -import { Preferences } from "@corbits-builder/schemas/preferences"; -import { - AskQuestions, - askQuestionsIssue, -} from "@corbits-builder/schemas/questions"; -import { DEFAULT_STACK } from "../config/packages.ts"; -import { - CODER_INPUTS, - DESIGNER_INPUTS, - startHere, - type InputKind, -} from "../config/inputs.ts"; -import { - agentDecidesApprovals, - asksOpenQuestions, - designConceptCount, - preferenceBlock, -} from "../config/preferences.ts"; -import { - DESIGN_CONCEPTS, - DESIGN_THEMES, - designConceptAssignment, - designLookIdeas, -} from "../config/design.ts"; -import { QUESTIONS_ASKED } from "../config/questions.ts"; -import { assignmentToWriterMessage } from "../workflows/writer-task.ts"; -import { coderTaskMessage } from "@corbits-builder/coder/contract"; - -export const DEPLOY_TOOLS = "@corbits-builder/deploy/tools"; -const CODER_DOCUMENTS = [...CODER_INPUTS, "design"] as const; -// A placeholder instruction repeated across turns ends the coordinator in a doom loop; a length -// floor the schema states gives it a refusal to act on instead. -const Instruction = - NativeSpecialistAssignment.get("instruction").and("string >= 40"); -const DeployWriter = type({ - "+": "reject", - kind: NativeDocumentKind, - instruction: Instruction, -}); -const APPROVABLE = [ - "problem_brief", - "constraints", - "approaches", - "product_requirements", - "build_plan", -] as const; -// The documents in the order each builds on the ones before it; the build plan follows the design. -const DOCUMENT_ORDER = [ - ...APPROVABLE.slice(0, 4), - "design", - "build_plan", -] as const; -const RequestApproval = type({ - "+": "reject", - kinds: type - .enumerated(...APPROVABLE, MATERIAL_KIND) - .array() - .atLeastLength(1), -}); - -/** The filed documents waiting on the person's request_approval at their current version. */ -export function awaitingApproval< - T extends { kind: string; version: number; approvedVersion?: number }, ->(filed: readonly T[], preferences: Preferences): T[] { - if (agentDecidesApprovals(preferences)) return []; - return filed.filter( - (item) => - (APPROVABLE as readonly string[]).includes(item.kind) && - item.approvedVersion !== item.version, - ); -} - -const ReviewArtifact = type({ - "+": "reject", - kind: NativeDocumentKind.or("'problem_brief'"), - instruction: Instruction, -}); -const DeployCoder = type({ "+": "reject", "instruction?": Instruction }); -const InstructCoder = type({ "+": "reject", instruction: Instruction }); -const DesignApp = type({ - "+": "reject", - instruction: Instruction, - lookIdeas: "string > 0", - concepts: DesignConcept.array().atLeastLength(1).atMostLength(5), -}); -const ArtifactList = type({ - "+": "ignore", - artifacts: type({ - "+": "ignore", - kind: "string > 0", - artifactId: "string > 0", - title: "string > 0", - version: "number.integer > 0", - sha256: /^[a-f0-9]{64}$/, - "approvedVersion?": "number.integer > 0", - "openQuestionCount?": "number.integer > 0", - }).array(), - inFlight: type({ - "+": "ignore", - runId: "string > 0", - role: "string > 0", - status: "'deployed' | 'running'", - "concept?": "string > 0", - }).array(), - preferences: Preferences, -}); -type Ref = typeof SourceRef.infer; - -interface DeployEnv extends BaseEnv { - capabilities: RuntimeCapabilities; -} - -class Refusal extends Error {} - -const notApproved = (kind: string, version: number) => - new Refusal( - kind + - "_not_approved: a person has not approved " + - kind + - " version " + - String(version) + - (kind === "design" - ? "; they approve a design by confirming a design option in the conversation" - : "; ask them with request_approval"), - ); - -// The deployments route answers only after it installs, probes and gates the source, each stage -// under its own native deadline: sidecar ensure and connection 120s each, the probe 60s. A shorter -// client deadline abandons a deploy the hub still completes. -const DEPLOY_TIMEOUT_MS = 300_000; - -// A turn's tool calls run concurrently (INFERENCE.md "Parallel execution"), so two starts of one -// unbounded role in the same turn would both find it missing from inFlight. -const starting = new Set(); -async function startOnce( - key: string, - role: string, - start: () => Promise, -): Promise { - if (starting.has(key)) - throw new Refusal( - role + "_starting: a " + role + " is already starting for this project", - ); - starting.add(key); - try { - return await start(); - } finally { - starting.delete(key); - } -} - -function validArguments( - schema: { assert: (value: unknown) => T }, - value: unknown, -): T { - try { - return schema.assert(value); - } catch (error) { - if (error instanceof TraversalError) - throw new Refusal("invalid_arguments: " + error.message); - throw error; - } -} - -// Authorization refusals stay a bare code; any other hub refusal carries the -// code with its field issues so the model can correct the call. -function hubRefusal(error: WorkflowHttpError): string { - const code = error.refusalCode ?? error.message; - if (error.status === 401 || error.status === 403 || !error.issues?.length) - return code; - return ( - code + - ": " + - error.issues.map((issue) => issue.path + " " + issue.message).join("; ") - ); -} - -const refusalText = (error: unknown) => - error instanceof Refusal - ? error.message - : error instanceof WorkflowHttpError - ? hubRefusal(error) - : error instanceof Error - ? error.name + ": " + error.message - : String(error); - -export function createDeployTools(options?: { - tenantId: string; - projectId: string; - domain: string; - workflows: { - writer: NativeSpecialistDeployment; - reviewer: NativeSpecialistDeployment; - coder: NativeSpecialistDeployment; - designer: NativeSpecialistDeployment; - }; - maxResultBytes: number; -}): AnnotatedToolFactory { - if (options) - for (const deployment of Object.values(options.workflows)) { - const checked = NativeSpecialistDeployment.assert(deployment); - if (!checked.sourceOfferingIds.includes(checked.defaultSourceOfferingId)) - throw new Error("invalid_specialist_default_offering"); - } - const definitions = [ - { - name: "request_approval", - description: - "Ask the person to approve the filed documents of the given kinds, each at its current version; for material, every attached or imported material. One ask covers every document ready for the person, so list them all together, an earlier document with the ones that follow it. Ask for approval once the documents have no open questions left; open questions the person must decide go to them with ask_questions first. Their approval is what lets the writer of the next document start from them, and a changed document is approved again.", - inputSchema: { ...RequestApproval.toJsonSchema() }, - }, - { - name: "ask_questions", - description: - "Put up to five questions to the person, each shown as a card they answer. id is a short slug unique within the call. kind single takes one option and multi any number. preselected holds the option values you recommend, at most one for single. allowOther lets the person answer in their own words. Their answers arrive as their next message.", - inputSchema: { ...AskQuestions.toJsonSchema() }, - }, - { - name: "deploy_writer", - description: - "Have a writer write one document of the given kind from the filed documents. instruction says what the document must cover or correct; the writer sees only the filed documents and the instruction, so put in it anything the person said that the documents do not record. A writer starts once each earlier document is filed and a person approved its current version. A document can be rewritten at any time. The finished document arrives later as a message.", - inputSchema: { ...DeployWriter.toJsonSchema() }, - }, - { - name: "review_artifact", - description: - "Ask a reviewer for a second opinion on the filed document of the given kind, checked against the problem brief. instruction says what to check. The review arrives later as a message; it is advice for you to weigh, and approval stays with the person.", - inputSchema: { ...ReviewArtifact.toJsonSchema() }, - }, - { - name: "deploy_coder", - description: - "Have the coder build the whole app from the filed problem brief, constraints, approaches, product requirements, build plan and design. The documents are the assignment; an optional instruction adds a note to it. It starts once a person approved the current version of each document: the design by confirming a design option in the conversation, the others through request_approval.", - inputSchema: { ...DeployCoder.toJsonSchema() }, - }, - { - name: "instruct_coder", - description: - "Send a change to the coder that is already building: a fix, an addition or a correction. It continues in the same workspace. A person approves every instruction.", - inputSchema: { ...InstructCoder.toJsonSchema() }, - }, - { - name: "design_app", - description: - "Have the designer make or change the app's design from the filed problem brief, constraints, approaches and product requirements: a clickable, responsive mockup with a DESIGN.md of its tokens, screens, states and flows. The coder builds only from a filed design. instruction says what to design or change; a designer already working takes it in the same design. lookIdeas carries the person's own ideas for the look in their words, or that they left the look to you, and every designer works from it. The designer starts once a person approves the instruction. Each design arrives later as a message.\n\nWhen the person wants one suggested design, give one concept: the look you suggest, shaped by their ideas. When they want to explore several, give a concept for each look, up to the number of design concepts the person asked for. One designer per concept makes the design in that concept's look and files it as that concept's design_option for the person to compare; a designer already working on a concept takes the instruction in the same option. Choose concepts that differ clearly from one another, from this catalog or of your own that fit the idea better. Each catalog concept starts from a design theme (" + - DESIGN_THEMES.map((theme) => theme.label).join(", ") + - ") and its direction says how its designer adjusts that theme's tokens; a concept of your own starts from the kit's neutral theme, so its direction names the colours, type and radii it wants. When the person chose a theme, design that one concept, with the changes they asked for in its direction:\n" + - DESIGN_CONCEPTS.map( - ({ label, direction }) => "- " + label + ": " + direction, - ).join("\n"), - inputSchema: { ...DesignApp.toJsonSchema() }, - }, - ]; - return defineTool({ - id: DEPLOY_TOOLS, - credentials: [{ handle: HUB_CREDENTIAL_HANDLE }], - requires: ["capabilities"], - definitions: [ - { name: "request_approval", approval: "ask" }, - { name: "ask_questions" }, - { name: "deploy_writer" }, - { name: "review_artifact" }, - { name: "deploy_coder" }, - { name: "instruct_coder", approval: "ask" }, - { name: "design_app", approval: "ask" }, - ], - factory: (env) => ({ - definitions, - run: async (call, signal) => { - if (call.name === "ask_questions") { - try { - const issue = askQuestionsIssue( - validArguments(AskQuestions, call.arguments), - ); - if (issue) throw new Refusal("invalid_questions: " + issue); - return { callId: call.id, content: QUESTIONS_ASKED }; - } catch (error) { - return { - callId: call.id, - content: refusalText(error), - isError: true, - }; - } - } - if (!options) { - try { - const scope = await resolveWorkflowProjectScope( - env.capabilities, - "coordinator", - signal, - ); - return createDeployTools({ - tenantId: scope.tenantId, - projectId: scope.projectId, - domain: scope.domain, - workflows: scope.workflows, - maxResultBytes: 300 * 1024, - })(env).run(call, signal); - } catch (error) { - return projectScopeFailure(call.id, error); - } - } - const projectPath = - "/api/tenants/" + - encodeURIComponent(options.tenantId) + - "/projects/" + - encodeURIComponent(options.projectId); - const hub = (path: string, body?: unknown, timeoutMs?: number) => - workflowHttpJson({ - capabilities: env.capabilities, - handle: HUB_CREDENTIAL_HANDLE, - path, - ...(body === undefined ? {} : { body }), - maxResponseBytes: options.maxResultBytes, - signal, - ...(timeoutMs === undefined ? {} : { timeoutMs }), - }); - const listed = async () => { - const list = ArtifactList.assert( - await hub(projectPath + "/artifacts"), - ); - const byKind = new Map(); - const materials: ArtifactRef[] = []; - const approved = new Map(); - const openQuestions = new Map(); - for (const item of list.artifacts) { - const { kind, artifactId, version, sha256, approvedVersion } = item; - if (kind === "design_option") continue; - if (kind === MATERIAL_KIND) - materials.push({ kind, artifactId, version, sha256 }); - byKind.set(kind, { artifactId, version, sha256 }); - if (approvedVersion !== undefined) - approved.set(kind, approvedVersion); - if (item.openQuestionCount) - openQuestions.set(kind, item.openQuestionCount); - } - return { - byKind, - materials, - approved, - openQuestions, - inFlight: list.inFlight, - preferences: list.preferences, - }; - }; - const readSource = async (ref: Ref) => { - const { kind, title, content } = ArtifactContent.assert( - await hub( - projectPath + - "/artifacts/" + - encodeURIComponent(ref.artifactId) + - "?version=" + - String(ref.version), - ), - ); - if (kind !== "design") return { kind, title, content }; - // A writer reads the design's DESIGN.md; the screens stay in the bundle the coder gets. - const { files } = DesignBundle.assert(JSON.parse(content)); - const screens = Object.keys(files).filter((path) => - path.startsWith("designs/"), - ); - return { - kind, - title, - content: files["DESIGN.md"] + "\n\nScreens: " + screens.join(", "), - }; - }; - // This handler runs only after the native approval resolved, so the - // versions it is about to hand off are the ones the person approved. - const recordApproval = ( - tool: "request_approval" | "instruct_coder", - artifacts: ArtifactRef[], - ) => hub(projectPath + "/artifact-approvals", { tool, artifacts }); - const refsOf = (byKind: Map) => - [...byKind].map(([kind, ref]) => ({ kind, ...ref })); - const required = (byKind: Map, kind: string) => { - const ref = byKind.get(kind); - if (!ref) - throw new Refusal( - kind + - "_not_filed: " + - (kind === "design" - ? "have the designer make it with design_app first" - : "write and file the " + kind + " first"), - ); - return ref; - }; - // A fresh deployment warms its workspace with the current versions, the ones named here. - const inputs = ( - kinds: readonly InputKind[], - byKind: Map, - ) => - kinds.map((kind) => ({ - kind, - version: required(byKind, kind).version, - })); - // Every assignment carries the person's preferences that apply to the receiving role. - const assign = ( - runId: string, - role: "writer" | "reviewer" | "coder" | "designer", - preferences: Preferences, - correlationId: string, - content: string, - ) => - env.capabilities.resolve("mail.transport").send( - { - to: [deriveRunAddress({ runId, domain: options.domain })], - type: "conversation.message", - correlationId, - content: content + preferenceBlock(preferences, role), - }, - signal, - ); - // Mail to a fresh deployment lands in its durable inbox and fires the - // run, so the deploy response is enough to send the assignment. - const deploy = async ( - role: "writer" | "reviewer" | "coder" | "designer", - preferences: Preferences, - correlationId: string, - content: string, - beforeAssign?: (runId: string) => Promise, - ) => { - const deployment = WorkflowDeploymentResponse.assert( - await hub( - "/api/tenants/" + - encodeURIComponent(options.tenantId) + - "/workflows/deployments", - options.workflows[role], - DEPLOY_TIMEOUT_MS, - ), - ); - await beforeAssign?.(deployment.id); - await assign( - deployment.id, - role, - preferences, - correlationId + "-" + deployment.id, - content, - ); - return { runId: deployment.id, role, status: deployment.status }; - }; - try { - signal.throwIfAborted(); - let result: unknown; - if (call.name === "request_approval") { - const { kinds } = validArguments(RequestApproval, call.arguments); - const { byKind, materials, openQuestions, preferences } = - await listed(); - const open = [...new Set(kinds)].flatMap((kind) => { - const count = openQuestions.get(kind); - return count - ? [`${kind} has ${count} open question${count > 1 ? "s" : ""}`] - : []; - }); - if (open.length && asksOpenQuestions(preferences)) - throw new Refusal( - "open_questions: " + - open.join(", ") + - "; read_artifact shows each one's openQuestions to put to the person with ask_questions, and the document revised with their answers can then be approved", - ); - const documents = [...new Set(kinds)].flatMap((kind) => { - if (kind !== MATERIAL_KIND) - return [{ kind, ...required(byKind, kind) }]; - if (!materials.length) - throw new Refusal( - "material_not_filed: the project has no material to approve", - ); - return materials; - }); - await recordApproval("request_approval", documents); - result = { approved: documents }; - } else if (call.name === "deploy_writer") { - const input = validArguments(DeployWriter, call.arguments); - const { byKind, approved, preferences } = await listed(); - const briefRef = required(byKind, "problem_brief"); - const position = DOCUMENT_ORDER.indexOf( - input.kind as (typeof DOCUMENT_ORDER)[number], - ); - const earlier = - position < 0 - ? DOCUMENT_ORDER.slice(0, 1) - : DOCUMENT_ORDER.slice(0, position); - // An imported project may lack an earlier document that its later ones were written without. - const writtenWithout = (index: number) => - DOCUMENT_ORDER.slice(index + 1).some((kind) => byKind.has(kind)); - for (const [index, kind] of earlier.entries()) { - const ref = byKind.get(kind); - if (!ref) { - if (writtenWithout(index)) continue; - throw new Refusal( - kind + - "_missing: " + - (kind === "design" - ? "have the designer make it with design_app" - : "write and file the " + kind) + - " before the " + - input.kind, - ); - } - if ( - !agentDecidesApprovals(preferences) && - approved.get(kind) !== ref.version - ) - throw notApproved(kind, ref.version); - } - const target = byKind.get(input.kind); - const sourceRefs = [...byKind] - .filter( - ([kind]) => - !["problem_brief", "app", input.kind].includes(kind) && - (kind !== "design" || input.kind === "build_plan"), - ) - .map(([, ref]) => ref); - result = await deploy( - "writer", - preferences, - "writer-" + input.kind, - await assignmentToWriterMessage( - { - projectId: options.projectId, - deliverableId: input.kind, - kind: input.kind, - briefRef, - sourceRefs, - output: { expectedVersion: target?.version ?? 0 }, - instruction: - input.kind === "build_plan" - ? input.instruction + - "\n\nDefault stack unless the person asked otherwise: " + - DEFAULT_STACK - : input.instruction, - }, - readSource, - target, - ), - ); - } else if (call.name === "review_artifact") { - const input = validArguments(ReviewArtifact, call.arguments); - const { byKind, preferences } = await listed(); - const briefRef = required(byKind, "problem_brief"); - const target = required(byKind, input.kind); - result = await deploy( - "reviewer", - preferences, - "reviewer-" + input.kind, - await assignmentToWriterMessage( - { - projectId: options.projectId, - deliverableId: input.kind, - kind: "engineering_review", - briefRef, - sourceRefs: [target], - output: { expectedVersion: target.version }, - instruction: input.instruction, - }, - readSource, - ), - ); - } else if (call.name === "deploy_coder") { - const input = validArguments(DeployCoder, call.arguments); - result = await startOnce( - options.projectId + ":coder", - "coder", - async () => { - const { byKind, approved, inFlight, preferences } = - await listed(); - for (const kind of CODER_DOCUMENTS) required(byKind, kind); - const live = inFlight.find((run) => run.role === "coder"); - if (live) - throw new Refusal( - "coder_already_running: the coder is building in run " + - live.runId + - "; send it changes with instruct_coder", - ); - // The design is approved by choosing it, whoever decides the other documents. - for (const kind of DOCUMENT_ORDER) { - const { version } = required(byKind, kind); - if ( - (kind === "design" || - !agentDecidesApprovals(preferences)) && - approved.get(kind) !== version - ) - throw notApproved(kind, version); - } - const design = required(byKind, "design"); - return deploy( - "coder", - preferences, - "coder", - startHere(inputs(CODER_INPUTS, byKind), design.version) + - coderTaskMessage(input.instruction), - ); - }, - ); - } else if (call.name === "instruct_coder") { - const input = validArguments(InstructCoder, call.arguments); - const { byKind, inFlight, preferences } = await listed(); - for (const kind of CODER_DOCUMENTS) required(byKind, kind); - // The coder is unbounded: the live run takes the change on its warm workspace. - const live = inFlight.find((run) => run.role === "coder"); - if (!live) - throw new Refusal( - "coder_not_running: no coder is building; start it with deploy_coder", - ); - await recordApproval("instruct_coder", refsOf(byKind)); - await assign( - live.runId, - "coder", - preferences, - "coder-" + live.runId + "-" + call.id, - coderTaskMessage(input.instruction), - ); - result = { runId: live.runId, role: "coder" }; - } else if (call.name === "design_app") { - const input = validArguments(DesignApp, call.arguments); - const { inFlight, preferences } = await listed(); - const limit = designConceptCount(preferences); - // A requested concept already at work takes the instruction rather than adding a designer. - const concepts = new Set( - inFlight.flatMap((run) => - run.role === "designer" && run.concept ? [run.concept] : [], - ), - ); - for (const concept of input.concepts) concepts.add(concept.label); - if (concepts.size > limit) - throw new Refusal( - "too_many_concepts: the person asked for at most " + - String(limit) + - " design concepts at a time", - ); - // Each concept has its own long-lived designer. - const design = (concept: DesignConcept) => - startOnce( - options.projectId + ":designer:" + concept.label, - "designer", - async () => { - const { byKind, inFlight, preferences } = await listed(); - for (const kind of DESIGNER_INPUTS) required(byKind, kind); - const message = - designLookIdeas(input.lookIdeas) + - coderTaskMessage(input.instruction); - // A designer still "deployed" never received its first assignment, so it is not reused. - const live = inFlight.find( - (run) => - run.role === "designer" && - run.status === "running" && - run.concept === concept.label, - ); - if (live) { - await assign( - live.runId, - "designer", - preferences, - "designer-" + live.runId + "-" + call.id, - message, - ); - return { - runId: live.runId, - role: "designer", - concept: concept.label, - }; - } - const started = await deploy( - "designer", - preferences, - "designer", - startHere(inputs(DESIGNER_INPUTS, byKind)) + - designConceptAssignment(concept) + - message, - (runId) => - hub(projectPath + "/design-concepts", { - runId, - concept, - }), - ); - return { ...started, concept: concept.label }; - }, - ); - // One concept that fails to start leaves the others at work, so each reports its own outcome. - result = (await Promise.allSettled(input.concepts.map(design))).map( - (outcome, index) => - outcome.status === "fulfilled" - ? outcome.value - : { - concept: input.concepts[index]!.label, - error: refusalText(outcome.reason), - }, - ); - } else - return { - callId: call.id, - content: "unknown_deploy_tool", - isError: true, - }; - return { callId: call.id, content: JSON.stringify(result) }; - } catch (error) { - return { - callId: call.id, - content: refusalText(error), - isError: true, - }; - } - }, - }), - }); -} diff --git a/apps/hub/src/projects/handoff-config.ts b/apps/hub/src/projects/handoff-config.ts new file mode 100644 index 00000000..b1b77bee --- /dev/null +++ b/apps/hub/src/projects/handoff-config.ts @@ -0,0 +1,36 @@ +import type { HandoffConfig } from "@corbits-builder/tools-handoff"; +import { DEFAULT_STACK } from "../config/packages.ts"; +import { CODER_INPUTS, DESIGNER_INPUTS, startHere } from "../config/inputs.ts"; +import { + agentDecidesApprovals, + asksOpenQuestions, + designConceptCount, + preferenceBlock, +} from "../config/preferences.ts"; +import { + DESIGN_CONCEPTS, + DESIGN_THEMES, + designConceptAssignment, + designLookIdeas, +} from "../config/design.ts"; +import { QUESTIONS_ASKED } from "../config/questions.ts"; +import { assignmentToWriterMessage } from "../workflows/writer-task.ts"; +import { projectScopeFailure } from "../workflows/project-scope.ts"; + +export const handoffConfig: HandoffConfig = { + failure: projectScopeFailure, + questionsAsked: QUESTIONS_ASKED, + defaultStack: DEFAULT_STACK, + designThemes: DESIGN_THEMES, + designConcepts: DESIGN_CONCEPTS, + designConceptAssignment, + designLookIdeas, + designerInputs: DESIGNER_INPUTS, + coderInputs: CODER_INPUTS, + startHere, + agentDecidesApprovals, + asksOpenQuestions, + designConceptCount, + preferenceBlock, + assignmentToWriterMessage, +}; diff --git a/apps/hub/src/workflows/entries/coordinator.ts b/apps/hub/src/workflows/entries/coordinator.ts index 569b78d7..998a53a3 100644 --- a/apps/hub/src/workflows/entries/coordinator.ts +++ b/apps/hub/src/workflows/entries/coordinator.ts @@ -2,7 +2,8 @@ import { createCoordinatorAgent } from "@corbits-builder/coordinator/agent"; import { createCoordinatorWorkflow } from "@corbits-builder/coordinator"; import { createArtifactTools } from "@corbits-builder/tools-artifacts/sidecar-bundle"; import { failure } from "../../artifacts/tools.ts"; -import { createDeployTools } from "../../projects/deploy-tools.ts"; +import { createDeployTools } from "@corbits-builder/tools-handoff/sidecar-bundle"; +import { handoffConfig } from "../../projects/handoff-config.ts"; import { createSkillTools } from "../../skills/tools.ts"; import { roleInference } from "../role-inference.ts"; import { @@ -27,7 +28,7 @@ const agent = createCoordinatorAgent({ inference: { ...source, parameters }, tools: [ createArtifactTools("coordinator", { failure }), - createDeployTools(), + createDeployTools(handoffConfig), createSkillTools(["problem-brief"]), ], }); diff --git a/bun.lock b/bun.lock index 95deac45..39586daf 100644 --- a/bun.lock +++ b/bun.lock @@ -69,6 +69,7 @@ "@corbits-builder/schemas": "workspace:*", "@corbits-builder/tools-artifacts": "workspace:*", "@corbits-builder/tools-design": "workspace:*", + "@corbits-builder/tools-handoff": "workspace:*", "@corbits-builder/tools-hub": "workspace:*", "@corbits-builder/writer": "workspace:*", "@corbits/agent-token": "0.2.2", @@ -183,6 +184,19 @@ "arktype": "catalog:", }, }, + "packages/tools-handoff": { + "name": "@corbits-builder/tools-handoff", + "version": "0.1.0-rc.2", + "dependencies": { + "@corbits-builder/coder": "workspace:*", + "@corbits-builder/schemas": "workspace:*", + "@corbits-builder/tools-hub": "workspace:*", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", + "@intx/workflow-deploy": "workspace:*", + "arktype": "catalog:", + }, + }, "packages/tools-hub": { "name": "@corbits-builder/tools-hub", "version": "0.1.0-rc.2", @@ -769,6 +783,8 @@ "@corbits-builder/tools-design": ["@corbits-builder/tools-design@workspace:packages/tools-design"], + "@corbits-builder/tools-handoff": ["@corbits-builder/tools-handoff@workspace:packages/tools-handoff"], + "@corbits-builder/tools-hub": ["@corbits-builder/tools-hub@workspace:packages/tools-hub"], "@corbits-builder/web": ["@corbits-builder/web@workspace:apps/web"], diff --git a/docs/DEV.md b/docs/DEV.md index 3a3d2e6b..2f0dbc79 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -18,6 +18,7 @@ running with Docker Compose is in | `packages/tools-design` | The designer's tools: filing a design option and the design check (`@corbits-builder/tools-design`). | | `packages/tools-hub` | The helpers the hub's agent tools use to call the hub and resolve a run's project (`@corbits-builder/tools-hub`). | | `packages/tools-artifacts` | The read and write tools agents use for project documents (`@corbits-builder/tools-artifacts`). | +| `packages/tools-handoff` | The coordinator's hand-off tools: approvals, questions and deploying the writer, reviewer, coder and designer (`@corbits-builder/tools-handoff`). | | `scripts/` | Dev and verification scripts: pin and vendor checks, the commit check, the Google sign-in emulator and React UI API generation. | | `deploy/` | The Dockerfile and Compose files ([SELF_HOST.md](https://github.com/corbitsdev/corbits-builder/blob/main/docs/SELF_HOST.md)). | diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 4e204da4..7e0ebcf6 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -74,7 +74,7 @@ run's own credential: - hand-offs and decisions: `request_approval`, `ask_questions`, `deploy_writer`, `review_artifact`, `design_app`, `deploy_coder`, `instruct_coder` - ([apps/hub/src/projects/deploy-tools.ts](https://github.com/corbitsdev/corbits-builder/blob/main/apps/hub/src/projects/deploy-tools.ts)); + ([packages/tools-handoff](https://github.com/corbitsdev/corbits-builder/tree/main/packages/tools-handoff)); - design filing: `file_design_option` ([packages/tools-design](https://github.com/corbitsdev/corbits-builder/tree/main/packages/tools-design)); - skills: `read_skill` @@ -125,6 +125,7 @@ Environment files, installs and build output never leave the workspace. | `packages/tools-design` | `@corbits-builder/tools-design` | The designer's design-option filing and design-check tools. | | `packages/tools-hub` | `@corbits-builder/tools-hub` | The hub HTTP/JSON and project-scope helpers agent tools use. | | `packages/tools-artifacts` | `@corbits-builder/tools-artifacts` | The document read and write tools and their logic. | +| `packages/tools-handoff` | `@corbits-builder/tools-handoff` | The coordinator's hand-off tools and their logic. | | `vendor/interchange` | | Interchange at the pinned revision. | | `deploy/` | | The Dockerfile and Compose files. | | `scripts/` | | Dev and verification scripts. | diff --git a/packages/tools-handoff/README.md b/packages/tools-handoff/README.md new file mode 100644 index 00000000..f89870a5 --- /dev/null +++ b/packages/tools-handoff/README.md @@ -0,0 +1,39 @@ +# Tools handoff + +`@corbits-builder/tools-handoff`: the coordinator's hand-off tools. +`request_approval` and `ask_questions` go to the person; `deploy_writer`, +`review_artifact`, `deploy_coder`, `instruct_coder` and `design_app` hand +work to the specialists. Each tool's logic is a plain function a workflow +action can call; `./sidecar-bundle` is the `defineTool` bundle over them. + +## Where it fits + +The [hub][hub]'s coordinator workflow attaches `createDeployTools` from +`./sidecar-bundle` with a `HandoffConfig` built from the hub's config: the +wording, design concepts, preferences, assignment builders and the hub's +`failure`, which logs a failed project lookup and returns the code the +model sees. It reaches the hub through [`packages/tools-hub`][tools-hub] +and checks what it gets with [`packages/schemas`][schemas]. + +## Layout + +- `src/index.ts`: the logic exports and `HandoffConfig`. +- `src/sidecar-bundle.ts`: `createDeployTools`, the `defineTool` bundle. +- `src/config.ts`: `HandoffConfig` and `HandoffScope`. +- `src/handoff.ts`: one function per tool. +- `src/definitions.ts`: the tool definitions and their input schemas. +- `src/project-hub.ts`: the project's hub routes and specialist deploys. +- `src/documents.ts`: the filed documents a hand-off requires. +- `src/start-once.ts`: one start at a time per specialist. +- `src/approvals.ts`: the approvable documents and `awaitingApproval`. +- `src/refusal.ts`: `Refusal`, the refusals the model sees. + +## Working on it + +The bundle id `@corbits-builder/deploy/tools` is part of every grant name +(`tool:/`); keep it. `bun run typecheck` checks every importer +and `bun run test` runs the tests. + +[hub]: https://github.com/corbitsdev/corbits-builder/tree/main/apps/hub +[schemas]: https://github.com/corbitsdev/corbits-builder/tree/main/packages/schemas +[tools-hub]: https://github.com/corbitsdev/corbits-builder/tree/main/packages/tools-hub diff --git a/packages/tools-handoff/package.json b/packages/tools-handoff/package.json new file mode 100644 index 00000000..deadcabf --- /dev/null +++ b/packages/tools-handoff/package.json @@ -0,0 +1,21 @@ +{ + "name": "@corbits-builder/tools-handoff", + "version": "0.1.0-rc.2", + "private": true, + "license": "SEE LICENSE IN ../../LICENSE.md", + "author": "Sawyer Cutler ", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./sidecar-bundle": "./src/sidecar-bundle.ts" + }, + "dependencies": { + "@corbits-builder/coder": "workspace:*", + "@corbits-builder/schemas": "workspace:*", + "@corbits-builder/tools-hub": "workspace:*", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", + "@intx/workflow-deploy": "workspace:*", + "arktype": "catalog:" + } +} diff --git a/packages/tools-handoff/src/approvals.ts b/packages/tools-handoff/src/approvals.ts new file mode 100644 index 00000000..933f850c --- /dev/null +++ b/packages/tools-handoff/src/approvals.ts @@ -0,0 +1,25 @@ +export const APPROVABLE = [ + "problem_brief", + "constraints", + "approaches", + "product_requirements", + "build_plan", +] as const; +// The documents in the order each builds on the ones before it; the build plan follows the design. +export const DOCUMENT_ORDER = [ + ...APPROVABLE.slice(0, 4), + "design", + "build_plan", +] as const; + +/** The filed documents waiting on the person's request_approval at their current version. */ +export function awaitingApproval< + T extends { kind: string; version: number; approvedVersion?: number }, +>(filed: readonly T[], agentApproves: boolean): T[] { + if (agentApproves) return []; + return filed.filter( + (item) => + (APPROVABLE as readonly string[]).includes(item.kind) && + item.approvedVersion !== item.version, + ); +} diff --git a/packages/tools-handoff/src/config.ts b/packages/tools-handoff/src/config.ts new file mode 100644 index 00000000..bd12fd97 --- /dev/null +++ b/packages/tools-handoff/src/config.ts @@ -0,0 +1,53 @@ +import type { ToolResult } from "@intx/types/runtime"; +import type { SourceRef } from "@corbits-builder/schemas"; +import type { DesignConcept } from "@corbits-builder/schemas/artifact"; +import type { + NativeSpecialistAssignment, + NativeSpecialistDeployment, +} from "@corbits-builder/schemas/native-specialist"; +import type { Preferences } from "@corbits-builder/schemas/preferences"; +import type { APPROVABLE } from "./approvals.ts"; + +// The filed documents a designer or coder workspace is warmed with. +export type InputKind = (typeof APPROVABLE)[number]; +export type SpecialistRole = "writer" | "reviewer" | "coder" | "designer"; +export type Ref = typeof SourceRef.infer; + +// The host's wording and policy the hand-off tools work by, baked into the bundle that carries them. +export interface HandoffConfig { + // Turns a failure to resolve the run's project into the tool result, so the host keeps its own + // logging and codes. + failure: (callId: string, error: unknown) => ToolResult; + questionsAsked: string; + defaultStack: string; + designThemes: readonly { label: string }[]; + designConcepts: readonly { label: string; direction: string }[]; + designConceptAssignment: (concept: DesignConcept) => string; + designLookIdeas: (ideas: string) => string; + designerInputs: readonly InputKind[]; + coderInputs: readonly InputKind[]; + startHere: ( + documents: readonly { kind: InputKind; version: number }[], + design?: number, + ) => string; + agentDecidesApprovals: (preferences: Preferences) => boolean; + asksOpenQuestions: (preferences: Preferences) => boolean; + designConceptCount: (preferences: Preferences) => number; + preferenceBlock: (preferences: Preferences, role: SpecialistRole) => string; + assignmentToWriterMessage: ( + assignment: typeof NativeSpecialistAssignment.infer, + read: ( + ref: Ref, + ) => Promise<{ kind: string; title: string; content: string }>, + current?: Ref, + ) => Promise; +} + +// The project a coordinator run hands work off for, and the specialists it deploys. +export interface HandoffScope { + tenantId: string; + projectId: string; + domain: string; + workflows: Record; + maxResultBytes: number; +} diff --git a/packages/tools-handoff/src/definitions.ts b/packages/tools-handoff/src/definitions.ts new file mode 100644 index 00000000..2e6a3f46 --- /dev/null +++ b/packages/tools-handoff/src/definitions.ts @@ -0,0 +1,96 @@ +import { type } from "arktype"; +import { + DesignConcept, + MATERIAL_KIND, +} from "@corbits-builder/schemas/artifact"; +import { + NativeDocumentKind, + NativeSpecialistAssignment, +} from "@corbits-builder/schemas/native-specialist"; +import { AskQuestions } from "@corbits-builder/schemas/questions"; +import { APPROVABLE } from "./approvals.ts"; +import type { HandoffConfig } from "./config.ts"; + +// A placeholder instruction repeated across turns ends the coordinator in a doom loop; a length +// floor the schema states gives it a refusal to act on instead. +const Instruction = + NativeSpecialistAssignment.get("instruction").and("string >= 40"); +export const DeployWriter = type({ + "+": "reject", + kind: NativeDocumentKind, + instruction: Instruction, +}); +export const RequestApproval = type({ + "+": "reject", + kinds: type + .enumerated(...APPROVABLE, MATERIAL_KIND) + .array() + .atLeastLength(1), +}); +export const ReviewArtifact = type({ + "+": "reject", + kind: NativeDocumentKind.or("'problem_brief'"), + instruction: Instruction, +}); +export const DeployCoder = type({ "+": "reject", "instruction?": Instruction }); +export const InstructCoder = type({ "+": "reject", instruction: Instruction }); +export const DesignApp = type({ + "+": "reject", + instruction: Instruction, + lookIdeas: "string > 0", + concepts: DesignConcept.array().atLeastLength(1).atMostLength(5), +}); + +export function handoffDefinitions( + config: Pick, +) { + return [ + { + name: "request_approval", + description: + "Ask the person to approve the filed documents of the given kinds, each at its current version; for material, every attached or imported material. One ask covers every document ready for the person, so list them all together, an earlier document with the ones that follow it. Ask for approval once the documents have no open questions left; open questions the person must decide go to them with ask_questions first. Their approval is what lets the writer of the next document start from them, and a changed document is approved again.", + inputSchema: { ...RequestApproval.toJsonSchema() }, + }, + { + name: "ask_questions", + description: + "Put up to five questions to the person, each shown as a card they answer. id is a short slug unique within the call. kind single takes one option and multi any number. preselected holds the option values you recommend, at most one for single. allowOther lets the person answer in their own words. Their answers arrive as their next message.", + inputSchema: { ...AskQuestions.toJsonSchema() }, + }, + { + name: "deploy_writer", + description: + "Have a writer write one document of the given kind from the filed documents. instruction says what the document must cover or correct; the writer sees only the filed documents and the instruction, so put in it anything the person said that the documents do not record. A writer starts once each earlier document is filed and a person approved its current version. A document can be rewritten at any time. The finished document arrives later as a message.", + inputSchema: { ...DeployWriter.toJsonSchema() }, + }, + { + name: "review_artifact", + description: + "Ask a reviewer for a second opinion on the filed document of the given kind, checked against the problem brief. instruction says what to check. The review arrives later as a message; it is advice for you to weigh, and approval stays with the person.", + inputSchema: { ...ReviewArtifact.toJsonSchema() }, + }, + { + name: "deploy_coder", + description: + "Have the coder build the whole app from the filed problem brief, constraints, approaches, product requirements, build plan and design. The documents are the assignment; an optional instruction adds a note to it. It starts once a person approved the current version of each document: the design by confirming a design option in the conversation, the others through request_approval.", + inputSchema: { ...DeployCoder.toJsonSchema() }, + }, + { + name: "instruct_coder", + description: + "Send a change to the coder that is already building: a fix, an addition or a correction. It continues in the same workspace. A person approves every instruction.", + inputSchema: { ...InstructCoder.toJsonSchema() }, + }, + { + name: "design_app", + description: + "Have the designer make or change the app's design from the filed problem brief, constraints, approaches and product requirements: a clickable, responsive mockup with a DESIGN.md of its tokens, screens, states and flows. The coder builds only from a filed design. instruction says what to design or change; a designer already working takes it in the same design. lookIdeas carries the person's own ideas for the look in their words, or that they left the look to you, and every designer works from it. The designer starts once a person approves the instruction. Each design arrives later as a message.\n\nWhen the person wants one suggested design, give one concept: the look you suggest, shaped by their ideas. When they want to explore several, give a concept for each look, up to the number of design concepts the person asked for. One designer per concept makes the design in that concept's look and files it as that concept's design_option for the person to compare; a designer already working on a concept takes the instruction in the same option. Choose concepts that differ clearly from one another, from this catalog or of your own that fit the idea better. Each catalog concept starts from a design theme (" + + config.designThemes.map((theme) => theme.label).join(", ") + + ") and its direction says how its designer adjusts that theme's tokens; a concept of your own starts from the kit's neutral theme, so its direction names the colours, type and radii it wants. When the person chose a theme, design that one concept, with the changes they asked for in its direction:\n" + + config.designConcepts + .map(({ label, direction }) => "- " + label + ": " + direction) + .join("\n"), + inputSchema: { ...DesignApp.toJsonSchema() }, + }, + ]; +} diff --git a/packages/tools-handoff/src/documents.ts b/packages/tools-handoff/src/documents.ts new file mode 100644 index 00000000..4e2e85f1 --- /dev/null +++ b/packages/tools-handoff/src/documents.ts @@ -0,0 +1,25 @@ +import type { InputKind, Ref } from "./config.ts"; +import { Refusal } from "./refusal.ts"; + +export const refsOf = (byKind: Map) => + [...byKind].map(([kind, ref]) => ({ kind, ...ref })); + +export const required = (byKind: Map, kind: string) => { + const ref = byKind.get(kind); + if (!ref) + throw new Refusal( + kind + + "_not_filed: " + + (kind === "design" + ? "have the designer make it with design_app first" + : "write and file the " + kind + " first"), + ); + return ref; +}; + +// A fresh deployment warms its workspace with the current versions, the ones named here. +export const inputs = (kinds: readonly InputKind[], byKind: Map) => + kinds.map((kind) => ({ + kind, + version: required(byKind, kind).version, + })); diff --git a/packages/tools-handoff/src/handoff.ts b/packages/tools-handoff/src/handoff.ts new file mode 100644 index 00000000..45e81395 --- /dev/null +++ b/packages/tools-handoff/src/handoff.ts @@ -0,0 +1,313 @@ +import { coderTaskMessage } from "@corbits-builder/coder/contract"; +import { + MATERIAL_KIND, + DesignConcept, +} from "@corbits-builder/schemas/artifact"; +import { + AskQuestions, + askQuestionsIssue, +} from "@corbits-builder/schemas/questions"; +import { DOCUMENT_ORDER } from "./approvals.ts"; +import type { HandoffConfig, HandoffScope } from "./config.ts"; +import { + DeployCoder, + DeployWriter, + DesignApp, + InstructCoder, + RequestApproval, + ReviewArtifact, +} from "./definitions.ts"; +import { inputs, refsOf, required } from "./documents.ts"; +import type { ProjectHub } from "./project-hub.ts"; +import { + notApproved, + Refusal, + refusalText, + validArguments, +} from "./refusal.ts"; +import { startOnce } from "./start-once.ts"; + +const NEW_DOCUMENT_VERSION = 0; + +// What a hand-off needs besides its arguments: the host's config, the run's project and its hub +// routes, and the tool call it answers. +export interface HandoffDeps { + config: HandoffConfig; + scope: HandoffScope; + project: ProjectHub; + callId: string; +} + +export function askQuestions( + args: unknown, + { config }: Pick, +): string { + const issue = askQuestionsIssue(validArguments(AskQuestions, args)); + if (issue) throw new Refusal("invalid_questions: " + issue); + return config.questionsAsked; +} + +export async function requestApproval( + args: unknown, + { config, project }: HandoffDeps, +) { + const { kinds } = validArguments(RequestApproval, args); + const { byKind, materials, openQuestions, preferences } = + await project.listed(); + const open = [...new Set(kinds)].flatMap((kind) => { + const count = openQuestions.get(kind); + return count + ? [`${kind} has ${count} open question${count > 1 ? "s" : ""}`] + : []; + }); + if (open.length && config.asksOpenQuestions(preferences)) + throw new Refusal( + "open_questions: " + + open.join(", ") + + "; read_artifact shows each one's openQuestions to put to the person with ask_questions, and the document revised with their answers can then be approved", + ); + const documents = [...new Set(kinds)].flatMap((kind) => { + if (kind !== MATERIAL_KIND) return [{ kind, ...required(byKind, kind) }]; + if (!materials.length) + throw new Refusal( + "material_not_filed: the project has no material to approve", + ); + return materials; + }); + await project.recordApproval("request_approval", documents); + return { approved: documents }; +} + +export async function deployWriter( + args: unknown, + { config, scope, project }: HandoffDeps, +) { + const input = validArguments(DeployWriter, args); + const { byKind, approved, preferences } = await project.listed(); + const briefRef = required(byKind, "problem_brief"); + const position = DOCUMENT_ORDER.indexOf( + input.kind as (typeof DOCUMENT_ORDER)[number], + ); + const earlier = + position < 0 + ? DOCUMENT_ORDER.slice(0, 1) + : DOCUMENT_ORDER.slice(0, position); + // An imported project may lack an earlier document that its later ones were written without. + const writtenWithout = (index: number) => + DOCUMENT_ORDER.slice(index + 1).some((kind) => byKind.has(kind)); + for (const [index, kind] of earlier.entries()) { + const ref = byKind.get(kind); + if (!ref) { + if (writtenWithout(index)) continue; + throw new Refusal( + kind + + "_missing: " + + (kind === "design" + ? "have the designer make it with design_app" + : "write and file the " + kind) + + " before the " + + input.kind, + ); + } + if ( + !config.agentDecidesApprovals(preferences) && + approved.get(kind) !== ref.version + ) + throw notApproved(kind, ref.version); + } + const target = byKind.get(input.kind); + const expectedVersion = target ? target.version : NEW_DOCUMENT_VERSION; + const sourceRefs = [...byKind] + .filter( + ([kind]) => + !["problem_brief", "app", input.kind].includes(kind) && + (kind !== "design" || input.kind === "build_plan"), + ) + .map(([, ref]) => ref); + return project.deploy( + "writer", + preferences, + "writer-" + input.kind, + await config.assignmentToWriterMessage( + { + projectId: scope.projectId, + deliverableId: input.kind, + kind: input.kind, + briefRef, + sourceRefs, + output: { expectedVersion }, + instruction: + input.kind === "build_plan" + ? input.instruction + + "\n\nDefault stack unless the person asked otherwise: " + + config.defaultStack + : input.instruction, + }, + project.readSource, + target, + ), + ); +} + +export async function reviewArtifact( + args: unknown, + { config, scope, project }: HandoffDeps, +) { + const input = validArguments(ReviewArtifact, args); + const { byKind, preferences } = await project.listed(); + const briefRef = required(byKind, "problem_brief"); + const target = required(byKind, input.kind); + return project.deploy( + "reviewer", + preferences, + "reviewer-" + input.kind, + await config.assignmentToWriterMessage( + { + projectId: scope.projectId, + deliverableId: input.kind, + kind: "engineering_review", + briefRef, + sourceRefs: [target], + output: { expectedVersion: target.version }, + instruction: input.instruction, + }, + project.readSource, + ), + ); +} + +export async function deployCoder( + args: unknown, + { config, scope, project }: HandoffDeps, +) { + const input = validArguments(DeployCoder, args); + return startOnce(scope.projectId + ":coder", "coder", async () => { + const { byKind, approved, inFlight, preferences } = await project.listed(); + for (const kind of [...config.coderInputs, "design"]) + required(byKind, kind); + const live = inFlight.find((run) => run.role === "coder"); + if (live) + throw new Refusal( + "coder_already_running: the coder is building in run " + + live.runId + + "; send it changes with instruct_coder", + ); + // The design is approved by choosing it, whoever decides the other documents. + for (const kind of DOCUMENT_ORDER) { + const { version } = required(byKind, kind); + if ( + (kind === "design" || !config.agentDecidesApprovals(preferences)) && + approved.get(kind) !== version + ) + throw notApproved(kind, version); + } + const design = required(byKind, "design"); + return project.deploy( + "coder", + preferences, + "coder", + config.startHere(inputs(config.coderInputs, byKind), design.version) + + coderTaskMessage(input.instruction), + ); + }); +} + +export async function instructCoder( + args: unknown, + { config, project, callId }: HandoffDeps, +) { + const input = validArguments(InstructCoder, args); + const { byKind, inFlight, preferences } = await project.listed(); + for (const kind of [...config.coderInputs, "design"]) required(byKind, kind); + // The coder is unbounded: the live run takes the change on its warm workspace. + const live = inFlight.find((run) => run.role === "coder"); + if (!live) + throw new Refusal( + "coder_not_running: no coder is building; start it with deploy_coder", + ); + await project.recordApproval("instruct_coder", refsOf(byKind)); + await project.assign( + live.runId, + "coder", + preferences, + "coder-" + live.runId + "-" + callId, + coderTaskMessage(input.instruction), + ); + return { runId: live.runId, role: "coder" }; +} + +export async function designApp( + args: unknown, + { config, scope, project, callId }: HandoffDeps, +) { + const input = validArguments(DesignApp, args); + const { inFlight, preferences } = await project.listed(); + const limit = config.designConceptCount(preferences); + // A requested concept already at work takes the instruction rather than adding a designer. + const concepts = new Set( + inFlight.flatMap((run) => + run.role === "designer" && run.concept ? [run.concept] : [], + ), + ); + for (const concept of input.concepts) concepts.add(concept.label); + if (concepts.size > limit) + throw new Refusal( + "too_many_concepts: the person asked for at most " + + String(limit) + + " design concepts at a time", + ); + // Each concept has its own long-lived designer. + const design = (concept: DesignConcept) => + startOnce( + scope.projectId + ":designer:" + concept.label, + "designer", + async () => { + const { byKind, inFlight, preferences } = await project.listed(); + for (const kind of config.designerInputs) required(byKind, kind); + const message = + config.designLookIdeas(input.lookIdeas) + + coderTaskMessage(input.instruction); + // A designer still "deployed" never received its first assignment, so it is not reused. + const live = inFlight.find( + (run) => + run.role === "designer" && + run.status === "running" && + run.concept === concept.label, + ); + if (live) { + await project.assign( + live.runId, + "designer", + preferences, + "designer-" + live.runId + "-" + callId, + message, + ); + return { + runId: live.runId, + role: "designer", + concept: concept.label, + }; + } + const started = await project.deploy( + "designer", + preferences, + "designer", + config.startHere(inputs(config.designerInputs, byKind)) + + config.designConceptAssignment(concept) + + message, + (runId) => project.recordDesignConcept({ runId, concept }), + ); + return { ...started, concept: concept.label }; + }, + ); + // One concept that fails to start leaves the others at work, so each reports its own outcome. + return (await Promise.allSettled(input.concepts.map(design))).map( + (outcome, index) => + outcome.status === "fulfilled" + ? outcome.value + : { + concept: input.concepts[index]!.label, + error: refusalText(outcome.reason), + }, + ); +} diff --git a/packages/tools-handoff/src/index.ts b/packages/tools-handoff/src/index.ts new file mode 100644 index 00000000..32c5d492 --- /dev/null +++ b/packages/tools-handoff/src/index.ts @@ -0,0 +1,20 @@ +export { APPROVABLE, awaitingApproval, DOCUMENT_ORDER } from "./approvals.ts"; +export type { + HandoffConfig, + HandoffScope, + InputKind, + SpecialistRole, +} from "./config.ts"; +export { handoffDefinitions } from "./definitions.ts"; +export { + askQuestions, + deployCoder, + deployWriter, + designApp, + instructCoder, + requestApproval, + reviewArtifact, + type HandoffDeps, +} from "./handoff.ts"; +export { projectHub, type ProjectHub } from "./project-hub.ts"; +export { Refusal, refusalText } from "./refusal.ts"; diff --git a/packages/tools-handoff/src/project-hub.ts b/packages/tools-handoff/src/project-hub.ts new file mode 100644 index 00000000..7e44a5d3 --- /dev/null +++ b/packages/tools-handoff/src/project-hub.ts @@ -0,0 +1,181 @@ +import { workflowHttpJson } from "@corbits-builder/tools-hub"; +import { WorkflowDeploymentResponse } from "@intx/types"; +import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; +import { deriveRunAddress } from "@intx/workflow-deploy"; +import { type } from "arktype"; +import { + ArtifactContent, + DesignBundle, + MATERIAL_KIND, + type ArtifactRef, +} from "@corbits-builder/schemas/artifact"; +import { Preferences } from "@corbits-builder/schemas/preferences"; +import type { + HandoffConfig, + HandoffScope, + Ref, + SpecialistRole, +} from "./config.ts"; + +const ArtifactList = type({ + "+": "ignore", + artifacts: type({ + "+": "ignore", + kind: "string > 0", + artifactId: "string > 0", + title: "string > 0", + version: "number.integer > 0", + sha256: /^[a-f0-9]{64}$/, + "approvedVersion?": "number.integer > 0", + "openQuestionCount?": "number.integer > 0", + }).array(), + inFlight: type({ + "+": "ignore", + runId: "string > 0", + role: "string > 0", + status: "'deployed' | 'running'", + "concept?": "string > 0", + }).array(), + preferences: Preferences, +}); + +// The deployments route answers only after it installs, probes and gates the source, each stage +// under its own native deadline: sidecar ensure and connection 120s each, the probe 60s. A shorter +// client deadline abandons a deploy the hub still completes. +const DEPLOY_TIMEOUT_MS = 300_000; + +export type ProjectHub = ReturnType; + +// The hub routes of one project, reached through the run's mediated hub credential. +export function projectHub(options: { + config: Pick; + scope: HandoffScope; + capabilities: RuntimeCapabilities; + signal: AbortSignal; +}) { + const { config, scope, capabilities, signal } = options; + const projectPath = + "/api/tenants/" + + encodeURIComponent(scope.tenantId) + + "/projects/" + + encodeURIComponent(scope.projectId); + const hub = (path: string, body?: unknown, timeoutMs?: number) => + workflowHttpJson({ + capabilities, + handle: HUB_CREDENTIAL_HANDLE, + path, + ...(body === undefined ? {} : { body }), + maxResponseBytes: scope.maxResultBytes, + signal, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); + const listed = async () => { + const list = ArtifactList.assert(await hub(projectPath + "/artifacts")); + const byKind = new Map(); + const materials: ArtifactRef[] = []; + const approved = new Map(); + const openQuestions = new Map(); + for (const item of list.artifacts) { + const { kind, artifactId, version, sha256, approvedVersion } = item; + if (kind === "design_option") continue; + if (kind === MATERIAL_KIND) + materials.push({ kind, artifactId, version, sha256 }); + byKind.set(kind, { artifactId, version, sha256 }); + if (approvedVersion !== undefined) approved.set(kind, approvedVersion); + if (item.openQuestionCount) + openQuestions.set(kind, item.openQuestionCount); + } + return { + byKind, + materials, + approved, + openQuestions, + inFlight: list.inFlight, + preferences: list.preferences, + }; + }; + const readSource = async (ref: Ref) => { + const { kind, title, content } = ArtifactContent.assert( + await hub( + projectPath + + "/artifacts/" + + encodeURIComponent(ref.artifactId) + + "?version=" + + String(ref.version), + ), + ); + if (kind !== "design") return { kind, title, content }; + // A writer reads the design's DESIGN.md; the screens stay in the bundle the coder gets. + const { files } = DesignBundle.assert(JSON.parse(content)); + const screens = Object.keys(files).filter((path) => + path.startsWith("designs/"), + ); + return { + kind, + title, + content: files["DESIGN.md"] + "\n\nScreens: " + screens.join(", "), + }; + }; + // A tool calls this only after the native approval resolved, so the + // versions it is about to hand off are the ones the person approved. + const recordApproval = ( + tool: "request_approval" | "instruct_coder", + artifacts: ArtifactRef[], + ) => hub(projectPath + "/artifact-approvals", { tool, artifacts }); + const recordDesignConcept = (body: unknown) => + hub(projectPath + "/design-concepts", body); + // Every assignment carries the person's preferences that apply to the receiving role. + const assign = ( + runId: string, + role: SpecialistRole, + preferences: Preferences, + correlationId: string, + content: string, + ) => + capabilities.resolve("mail.transport").send( + { + to: [deriveRunAddress({ runId, domain: scope.domain })], + type: "conversation.message", + correlationId, + content: content + config.preferenceBlock(preferences, role), + }, + signal, + ); + // Mail to a fresh deployment lands in its durable inbox and fires the + // run, so the deploy response is enough to send the assignment. + const deploy = async ( + role: SpecialistRole, + preferences: Preferences, + correlationId: string, + content: string, + beforeAssign?: (runId: string) => Promise, + ) => { + const deployment = WorkflowDeploymentResponse.assert( + await hub( + "/api/tenants/" + + encodeURIComponent(scope.tenantId) + + "/workflows/deployments", + scope.workflows[role], + DEPLOY_TIMEOUT_MS, + ), + ); + await beforeAssign?.(deployment.id); + await assign( + deployment.id, + role, + preferences, + correlationId + "-" + deployment.id, + content, + ); + return { runId: deployment.id, role, status: deployment.status }; + }; + return { + listed, + readSource, + recordApproval, + recordDesignConcept, + assign, + deploy, + }; +} diff --git a/packages/tools-handoff/src/refusal.ts b/packages/tools-handoff/src/refusal.ts new file mode 100644 index 00000000..7a52e156 --- /dev/null +++ b/packages/tools-handoff/src/refusal.ts @@ -0,0 +1,49 @@ +import { WorkflowHttpError } from "@corbits-builder/tools-hub"; +import { TraversalError } from "arktype"; + +export class Refusal extends Error {} + +export const notApproved = (kind: string, version: number) => + new Refusal( + kind + + "_not_approved: a person has not approved " + + kind + + " version " + + String(version) + + (kind === "design" + ? "; they approve a design by confirming a design option in the conversation" + : "; ask them with request_approval"), + ); + +export function validArguments( + schema: { assert: (value: unknown) => T }, + value: unknown, +): T { + try { + return schema.assert(value); + } catch (error) { + if (error instanceof TraversalError) + throw new Refusal("invalid_arguments: " + error.message); + throw error; + } +} + +// Authorization refusals stay a bare code; any other hub refusal carries the +// code with its field issues so the model can correct the call. +function hubRefusal(error: WorkflowHttpError): string { + const code = error.refusalCode ?? error.message; + if (error.status === 401 || error.status === 403 || !error.issues?.length) + return code; + return ( + code + + ": " + + error.issues.map((issue) => issue.path + " " + issue.message).join("; ") + ); +} + +export function refusalText(error: unknown): string { + if (error instanceof Refusal) return error.message; + if (error instanceof WorkflowHttpError) return hubRefusal(error); + if (error instanceof Error) return error.name + ": " + error.message; + return String(error); +} diff --git a/packages/tools-handoff/src/sidecar-bundle.ts b/packages/tools-handoff/src/sidecar-bundle.ts new file mode 100644 index 00000000..605dc22f --- /dev/null +++ b/packages/tools-handoff/src/sidecar-bundle.ts @@ -0,0 +1,132 @@ +import { resolveWorkflowProjectScope } from "@corbits-builder/tools-hub"; +import { + defineTool, + type AnnotatedToolFactory, + type BaseEnv, +} from "@intx/agent/authoring"; +import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; +import { NativeSpecialistDeployment } from "@corbits-builder/schemas/native-specialist"; +import type { HandoffConfig, HandoffScope } from "./config.ts"; +import { handoffDefinitions } from "./definitions.ts"; +import { + askQuestions, + deployCoder, + deployWriter, + designApp, + instructCoder, + requestApproval, + reviewArtifact, + type HandoffDeps, +} from "./handoff.ts"; +import { projectHub } from "./project-hub.ts"; +import { refusalText } from "./refusal.ts"; + +export const DEPLOY_TOOLS = "@corbits-builder/deploy/tools"; + +interface DeployEnv extends BaseEnv { + capabilities: RuntimeCapabilities; +} + +const handoffs: Record unknown> = + { + request_approval: requestApproval, + deploy_writer: deployWriter, + review_artifact: reviewArtifact, + deploy_coder: deployCoder, + instruct_coder: instructCoder, + design_app: designApp, + }; + +// Without a scope, each call resolves the run's project from the hub first. +export function createDeployTools( + config: HandoffConfig, + scope?: HandoffScope, +): AnnotatedToolFactory { + if (scope) + for (const deployment of Object.values(scope.workflows)) { + const checked = NativeSpecialistDeployment.assert(deployment); + if (!checked.sourceOfferingIds.includes(checked.defaultSourceOfferingId)) + throw new Error("invalid_specialist_default_offering"); + } + const definitions = handoffDefinitions(config); + return defineTool({ + id: DEPLOY_TOOLS, + credentials: [{ handle: HUB_CREDENTIAL_HANDLE }], + requires: ["capabilities"], + definitions: [ + { name: "request_approval", approval: "ask" }, + { name: "ask_questions" }, + { name: "deploy_writer" }, + { name: "review_artifact" }, + { name: "deploy_coder" }, + { name: "instruct_coder", approval: "ask" }, + { name: "design_app", approval: "ask" }, + ], + factory: (env) => ({ + definitions, + run: async (call, signal) => { + if (call.name === "ask_questions") { + try { + return { + callId: call.id, + content: askQuestions(call.arguments, { config }), + }; + } catch (error) { + return { + callId: call.id, + content: refusalText(error), + isError: true, + }; + } + } + if (!scope) { + try { + const resolved = await resolveWorkflowProjectScope( + env.capabilities, + "coordinator", + signal, + ); + return createDeployTools(config, { + tenantId: resolved.tenantId, + projectId: resolved.projectId, + domain: resolved.domain, + workflows: resolved.workflows, + maxResultBytes: 300 * 1024, + })(env).run(call, signal); + } catch (error) { + return config.failure(call.id, error); + } + } + const handoff = handoffs[call.name]; + try { + signal.throwIfAborted(); + if (!handoff) + return { + callId: call.id, + content: "unknown_deploy_tool", + isError: true, + }; + const result = await handoff(call.arguments, { + config, + scope, + project: projectHub({ + config, + scope, + capabilities: env.capabilities, + signal, + }), + callId: call.id, + }); + return { callId: call.id, content: JSON.stringify(result) }; + } catch (error) { + return { + callId: call.id, + content: refusalText(error), + isError: true, + }; + } + }, + }), + }); +} diff --git a/packages/tools-handoff/src/start-once.ts b/packages/tools-handoff/src/start-once.ts new file mode 100644 index 00000000..38557e07 --- /dev/null +++ b/packages/tools-handoff/src/start-once.ts @@ -0,0 +1,21 @@ +import { Refusal } from "./refusal.ts"; + +// A turn's tool calls run concurrently (INFERENCE.md "Parallel execution"), so two starts of one +// unbounded role in the same turn would both find it missing from inFlight. +const starting = new Set(); +export async function startOnce( + key: string, + role: string, + start: () => Promise, +): Promise { + if (starting.has(key)) + throw new Refusal( + role + "_starting: a " + role + " is already starting for this project", + ); + starting.add(key); + try { + return await start(); + } finally { + starting.delete(key); + } +}