From b788a220e98c59f2ecd46b54f451469d1b7b4a1b Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Thu, 3 Sep 2026 11:38:42 -0400 Subject: [PATCH 1/7] feat(client-runtime): expose the agent persona catalog --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/j5/agents/agentPersonaRouting.test.ts | 1 + .../src/j5/agents/agentPersonaRouting.ts | 1 + apps/server/src/ws.ts | 7 ++ packages/client-runtime/package.json | 4 + .../src/state/agentPersonas.test.ts | 96 +++++++++++++++++++ .../client-runtime/src/state/agentPersonas.ts | 87 +++++++++++++++++ .../client-runtime/src/state/orchestration.ts | 5 + packages/contracts/src/j5/agentPersona.ts | 1 + packages/contracts/src/orchestrationV2.ts | 5 + packages/contracts/src/rpc.ts | 10 ++ 11 files changed, 218 insertions(+) create mode 100644 packages/client-runtime/src/state/agentPersonas.test.ts create mode 100644 packages/client-runtime/src/state/agentPersonas.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 77cc6b1cd51eb..19a3d93c4028a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -22,6 +22,7 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; */ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, + [ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, diff --git a/apps/server/src/j5/agents/agentPersonaRouting.test.ts b/apps/server/src/j5/agents/agentPersonaRouting.test.ts index d105ea11ce4b8..53059d61ebddf 100644 --- a/apps/server/src/j5/agents/agentPersonaRouting.test.ts +++ b/apps/server/src/j5/agents/agentPersonaRouting.test.ts @@ -304,6 +304,7 @@ describe("agent persona routing", () => { assert.equal(catalog.personas.length, 11); assert.equal(catalog.personas[0]?.personaId, "scout"); + assert.equal(catalog.personas[0]?.definitionVersion, 1); assert.equal(catalog.personas[0]?.acceptedInput, "Evidence request or prompt"); assert.equal(catalog.personas[0]?.outputArtifact, "ContextBrief"); assert.deepEqual(catalog.personas[0]?.availability, { diff --git a/apps/server/src/j5/agents/agentPersonaRouting.ts b/apps/server/src/j5/agents/agentPersonaRouting.ts index 7a4e0a7cedacd..097ba64a2c94b 100644 --- a/apps/server/src/j5/agents/agentPersonaRouting.ts +++ b/apps/server/src/j5/agents/agentPersonaRouting.ts @@ -181,6 +181,7 @@ export function buildBuiltInAgentPersonaCatalog( }); return { personaId: definition.id, + definitionVersion: definition.version, displayName: definition.displayName, description: definition.description, acceptedInput: definition.acceptedInput, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 329011c482f40..ac6e765f41594 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -133,6 +133,7 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import { buildBuiltInAgentPersonaCatalog } from "./j5/agents/agentPersonaRouting.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -1381,6 +1382,12 @@ const makeWsRpcLayer = ( : {}), }, ), + [ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog]: (_input) => + observeRpcEffect( + ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog, + providerRegistry.getProviders.pipe(Effect.map(buildBuiltInAgentPersonaCatalog)), + { "rpc.aggregate": "orchestrationV2" }, + ), [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => observeRpcEffect( ORCHESTRATION_V2_WS_METHODS.getWorkflowScript, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index ce79c0ac00d62..cf0e5a37d27da 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -87,6 +87,10 @@ "types": "./src/state/auth.ts", "default": "./src/state/auth.ts" }, + "./state/agent-personas": { + "types": "./src/state/agentPersonas.ts", + "default": "./src/state/agentPersonas.ts" + }, "./state/assets": { "types": "./src/state/assets.ts", "default": "./src/state/assets.ts" diff --git a/packages/client-runtime/src/state/agentPersonas.test.ts b/packages/client-runtime/src/state/agentPersonas.test.ts new file mode 100644 index 0000000000000..9b99d6d9e9e7d --- /dev/null +++ b/packages/client-runtime/src/state/agentPersonas.test.ts @@ -0,0 +1,96 @@ +import { + BUILT_IN_AGENT_PERSONA_IDS, + ProviderDriverKind, + ProviderInstanceId, + type OrchestrationV2AgentPersonaCatalog, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { presentAgentPersonaAssignment, presentAgentPersonaCatalog } from "./agentPersonas.ts"; + +const catalog: OrchestrationV2AgentPersonaCatalog = { + personas: BUILT_IN_AGENT_PERSONA_IDS.map((personaId, index) => ({ + personaId, + definitionVersion: 1, + displayName: personaId, + description: `${personaId} description`, + acceptedInput: `${personaId} input`, + outputArtifact: "ContextBrief", + defaultAuthorityPolicy: "read-only", + allowedAuthorityPolicies: ["read-only"], + availability: + index === 0 + ? { + status: "available", + resolvedRoute: "fallback", + resolvedDriver: ProviderDriverKind.make("codex"), + resolvedModelSelection: { + instanceId: ProviderInstanceId.make("remote-codex"), + model: "server-selected-model", + }, + } + : { status: "unavailable", reason: "routes-unavailable" }, + })), +}; + +describe("agent persona catalog presentation", () => { + it("presents a durable assignment with its immutable provider route", () => { + expect( + presentAgentPersonaAssignment({ + personaId: "critic", + definitionVersion: 1, + authorityPolicy: "critic-fix", + resolvedRoute: "fallback", + resolvedDriver: ProviderDriverKind.make("codex"), + resolvedModelSelection: { + instanceId: ProviderInstanceId.make("remote-codex"), + model: "gpt-5.6-terra", + options: [{ id: "reasoningEffort", value: "high" }], + }, + }), + ).toEqual({ + personaLabel: "Critic · Fix", + routeLabel: "Codex · gpt-5.6-terra · high", + }); + }); + + it("preserves all eleven server-provided personas for every client", () => { + expect(presentAgentPersonaCatalog(catalog).map(({ personaId }) => personaId)).toEqual([ + ...BUILT_IN_AGENT_PERSONA_IDS, + ]); + }); + + it("presents the server-resolved remote route without recalculating it", () => { + expect(presentAgentPersonaCatalog(catalog)[0]).toMatchObject({ + availability: "available", + availabilityLabel: "Available", + route: "Codex · server-selected-model · fallback", + }); + }); + + it("presents unavailable personas as clearly blocked", () => { + expect(presentAgentPersonaCatalog(catalog)[1]).toMatchObject({ + availability: "blocked", + availabilityLabel: "Blocked", + route: "Primary and fallback models unavailable", + }); + }); + + it("distinguishes an unenforceable authority boundary from missing models", () => { + const authorityBlocked = { + personas: [ + { + ...catalog.personas[1]!, + availability: { + status: "unavailable" as const, + reason: "authority-not-enforceable" as const, + }, + }, + ], + }; + + expect(presentAgentPersonaCatalog(authorityBlocked)[0]?.route).toBe( + "Required authority is not yet enforceable", + ); + }); +}); diff --git a/packages/client-runtime/src/state/agentPersonas.ts b/packages/client-runtime/src/state/agentPersonas.ts new file mode 100644 index 0000000000000..6a9626f1e0dc7 --- /dev/null +++ b/packages/client-runtime/src/state/agentPersonas.ts @@ -0,0 +1,87 @@ +import type { + AgentPersonaAuthorityPolicy, + BuiltInAgentPersonaId, + OrchestrationV2AgentPersonaAssignment, + OrchestrationV2AgentPersonaCatalog, +} from "@t3tools/contracts"; + +const AUTHORITY_LABELS: Readonly> = { + "read-only": "Read only", + "workspace-write": "Workspace write", + "critic-review": "Review only", + "critic-fix": "Targeted fixes", + diagnostic: "Diagnostic writes", + "publish-only": "Publish only", +}; + +export interface AgentPersonaCatalogRow { + readonly personaId: BuiltInAgentPersonaId; + readonly displayName: string; + readonly description: string; + readonly acceptedInput: string; + readonly outputArtifact: string; + readonly authority: string; + readonly availability: "available" | "blocked"; + readonly availabilityLabel: "Available" | "Blocked"; + readonly route: string; +} + +export interface AgentPersonaAssignmentPresentation { + readonly personaLabel: string; + readonly routeLabel: string; +} + +function providerLabel(driver: OrchestrationV2AgentPersonaAssignment["resolvedDriver"]): string { + return driver === "claudeAgent" ? "Claude" : "Codex"; +} + +export function presentAgentPersonaAssignment( + assignment: OrchestrationV2AgentPersonaAssignment, +): AgentPersonaAssignmentPresentation { + const name = `${assignment.personaId[0]?.toUpperCase()}${assignment.personaId.slice(1)}`; + const mode = + assignment.authorityPolicy === "critic-fix" + ? " · Fix" + : assignment.personaId === "critic" + ? " · Review" + : ""; + const provider = providerLabel(assignment.resolvedDriver); + const effort = assignment.resolvedModelSelection.options?.find( + ({ id }) => id === "reasoningEffort" || id === "effort", + )?.value; + + return { + personaLabel: `${name}${mode}`, + routeLabel: [provider, assignment.resolvedModelSelection.model, effort] + .filter((part) => part !== undefined) + .join(" · "), + }; +} + +export function presentAgentPersonaCatalog( + catalog: OrchestrationV2AgentPersonaCatalog, +): ReadonlyArray { + return catalog.personas.map((persona) => { + const available = persona.availability.status === "available"; + return { + personaId: persona.personaId, + displayName: persona.displayName, + description: persona.description, + acceptedInput: persona.acceptedInput, + outputArtifact: persona.outputArtifact, + authority: persona.allowedAuthorityPolicies + .map( + (policy) => + `${AUTHORITY_LABELS[policy]}${policy === persona.defaultAuthorityPolicy ? " (default)" : ""}`, + ) + .join(", "), + availability: available ? "available" : "blocked", + availabilityLabel: available ? "Available" : "Blocked", + route: available + ? `${providerLabel(persona.availability.resolvedDriver)} · ${persona.availability.resolvedModelSelection.model} · ${persona.availability.resolvedRoute}` + : persona.availability.reason === "authority-not-enforceable" + ? "Required authority is not yet enforceable" + : "Primary and fallback models unavailable", + }; + }); +} diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index 728c085937315..8bc7afe1647bc 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -17,6 +17,11 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration-v2:dispatch-command", tag: ORCHESTRATION_V2_WS_METHODS.dispatchCommand, }), + agentPersonaCatalog: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration-v2:agent-persona-catalog", + tag: ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog, + staleTimeMs: 30_000, + }), threadProjection: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration-v2:thread-projection", tag: ORCHESTRATION_V2_WS_METHODS.getThreadProjection, diff --git a/packages/contracts/src/j5/agentPersona.ts b/packages/contracts/src/j5/agentPersona.ts index 4ca5410116bc0..a11ce407c5d4d 100644 --- a/packages/contracts/src/j5/agentPersona.ts +++ b/packages/contracts/src/j5/agentPersona.ts @@ -87,6 +87,7 @@ export type OrchestrationV2AgentPersonaAvailability = /** Environment-specific, presentation-safe view of one built-in persona. */ export const OrchestrationV2AgentPersonaCatalogEntry = Schema.Struct({ personaId: BuiltInAgentPersonaId, + definitionVersion: PositiveInt, displayName: TrimmedNonEmptyString, description: TrimmedNonEmptyString, acceptedInput: TrimmedNonEmptyString, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 6c140393cf0c2..7ecd24b9865cc 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2368,6 +2368,7 @@ export type OrchestrationV2PublicCommand = typeof OrchestrationV2PublicCommand.T export const ORCHESTRATION_V2_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", + getAgentPersonaCatalog: "orchestration.getAgentPersonaCatalog", getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", searchThreads: "orchestration.searchThreads", @@ -2666,6 +2667,10 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2PublicCommand, output: OrchestrationV2DispatchCommandResult, }, + getAgentPersonaCatalog: { + input: Schema.Struct({}), + output: OrchestrationV2AgentPersonaCatalog, + }, getTurnDiff: { input: OrchestrationGetTurnDiffInput, output: OrchestrationGetTurnDiffResult, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 399c8883058f9..6de7bed767741 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1111,6 +1111,15 @@ export const WsOrchestrationV2DispatchCommandRpc = Rpc.make( }, ); +export const WsOrchestrationV2GetAgentPersonaCatalogRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog, + { + payload: OrchestrationV2RpcSchemas.getAgentPersonaCatalog.input, + success: OrchestrationV2RpcSchemas.getAgentPersonaCatalog.output, + error: EnvironmentAuthorizationError, + }, +); + export const WsOrchestrationV2GetTurnDiffRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.getTurnDiff, { payload: OrchestrationV2RpcSchemas.getTurnDiff.input, success: OrchestrationV2RpcSchemas.getTurnDiff.output, @@ -1417,6 +1426,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeBackgroundPolicyRpc, WsSubscribeResourceTelemetryRpc, WsOrchestrationV2DispatchCommandRpc, + WsOrchestrationV2GetAgentPersonaCatalogRpc, WsOrchestrationV2GetWorkflowScriptRpc, WsOrchestrationV2GetTurnDiffRpc, WsOrchestrationV2GetFullThreadDiffRpc, From 9b9e4db5927567b2b80877250ac21c2ce2d4eb3a Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Tue, 8 Sep 2026 10:30:47 -0400 Subject: [PATCH 2/7] feat(agents): manage portable libraries and imported definitions --- apps/server/src/auth/RpcAuthorization.test.ts | 25 + apps/server/src/auth/RpcAuthorization.ts | 6 + .../j5/agents/agentPersonaAssignment.test.ts | 16 +- .../src/j5/agents/agentPersonaAssignment.ts | 14 +- .../src/j5/agents/agentPersonaLaunch.ts | 59 ++ .../src/j5/agents/agentPersonaLibrary.test.ts | 856 ++++++++++++++++++ .../src/j5/agents/agentPersonaLibrary.ts | 448 +++++++++ .../src/j5/agents/agentPersonaPrompts.ts | 38 +- .../j5/agents/agentPersonaProviderPolicy.ts | 1 + .../src/j5/agents/agentPersonaRouting.test.ts | 42 +- .../src/j5/agents/agentPersonaRouting.ts | 18 +- .../src/j5/agents/agentPersonaRuntime.ts | 49 + apps/server/src/j5/agents/agentPersonas.ts | 263 ++---- .../src/j5/agents/examples/advocate.json | 26 + .../src/j5/agents/examples/builder.json | 26 + .../server/src/j5/agents/examples/critic.json | 26 + .../server/src/j5/agents/examples/herald.json | 26 + .../src/j5/agents/examples/investigator.json | 26 + .../src/j5/agents/examples/navigator.json | 26 + .../src/j5/agents/examples/prosecutor.json | 26 + .../src/j5/agents/examples/publisher.json | 26 + apps/server/src/j5/agents/examples/scout.json | 26 + .../server/src/j5/agents/examples/sentry.json | 26 + .../src/j5/agents/examples/skeptic.json | 26 + .../FoundationPersistence.test.ts | 6 +- .../src/orchestration-v2/Orchestrator.ts | 18 +- .../src/orchestration-v2/RuntimePolicy.ts | 74 +- .../ThreadLaunchService.test.ts | 127 +++ .../orchestration-v2/ThreadLaunchService.ts | 72 +- apps/server/src/ws.ts | 120 ++- .../src/state/agentPersonas.test.ts | 318 ++++++- .../client-runtime/src/state/agentPersonas.ts | 177 +++- .../client-runtime/src/state/orchestration.ts | 26 +- packages/contracts/src/j5/agentPersona.ts | 83 +- .../contracts/src/orchestrationV2.test.ts | 28 + packages/contracts/src/orchestrationV2.ts | 34 + packages/contracts/src/rpc.ts | 65 +- 37 files changed, 2892 insertions(+), 377 deletions(-) create mode 100644 apps/server/src/j5/agents/agentPersonaLaunch.ts create mode 100644 apps/server/src/j5/agents/agentPersonaLibrary.test.ts create mode 100644 apps/server/src/j5/agents/agentPersonaLibrary.ts create mode 100644 apps/server/src/j5/agents/agentPersonaRuntime.ts create mode 100644 apps/server/src/j5/agents/examples/advocate.json create mode 100644 apps/server/src/j5/agents/examples/builder.json create mode 100644 apps/server/src/j5/agents/examples/critic.json create mode 100644 apps/server/src/j5/agents/examples/herald.json create mode 100644 apps/server/src/j5/agents/examples/investigator.json create mode 100644 apps/server/src/j5/agents/examples/navigator.json create mode 100644 apps/server/src/j5/agents/examples/prosecutor.json create mode 100644 apps/server/src/j5/agents/examples/publisher.json create mode 100644 apps/server/src/j5/agents/examples/scout.json create mode 100644 apps/server/src/j5/agents/examples/sentry.json create mode 100644 apps/server/src/j5/agents/examples/skeptic.json diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 25971b0c0aecf..453d7718e2273 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -4,6 +4,7 @@ import { AuthRelayReadScope, AuthRelayWriteScope, WS_METHODS, + ORCHESTRATION_V2_WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; @@ -54,6 +55,30 @@ describe("RPC authorization scopes", () => { ); }); + it("requires operate access to import, edit, toggle, or remove persona definitions", () => { + expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.editImportedAgentPersona)).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.removeAgentPersona)).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.removeSourceAgentPersona)).toBe( + AuthOrchestrationOperateScope, + ); + expect( + requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.setImportedAgentPersonaEnabled), + ).toBe(AuthOrchestrationOperateScope); + expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.importAgentPersonas)).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.removeImportedAgentPersona)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 19a3d93c4028a..2d75da95285b6 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -23,6 +23,12 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog]: AuthOrchestrationReadScope, + [ORCHESTRATION_V2_WS_METHODS.importAgentPersonas]: AuthOrchestrationOperateScope, + [ORCHESTRATION_V2_WS_METHODS.removeImportedAgentPersona]: AuthOrchestrationOperateScope, + [ORCHESTRATION_V2_WS_METHODS.removeSourceAgentPersona]: AuthOrchestrationOperateScope, + [ORCHESTRATION_V2_WS_METHODS.removeAgentPersona]: AuthOrchestrationOperateScope, + [ORCHESTRATION_V2_WS_METHODS.editImportedAgentPersona]: AuthOrchestrationOperateScope, + [ORCHESTRATION_V2_WS_METHODS.setImportedAgentPersonaEnabled]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, diff --git a/apps/server/src/j5/agents/agentPersonaAssignment.test.ts b/apps/server/src/j5/agents/agentPersonaAssignment.test.ts index abae14b653ff1..d5cf6731cfde7 100644 --- a/apps/server/src/j5/agents/agentPersonaAssignment.test.ts +++ b/apps/server/src/j5/agents/agentPersonaAssignment.test.ts @@ -2,8 +2,8 @@ import { assert, describe, it } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { - buildBuiltInAgentPersonaAssignment, - validateBuiltInAgentPersonaAssignment, + buildAgentPersonaAssignment, + validateAgentPersonaAssignment, } from "./agentPersonaAssignment.ts"; const criticRoute = { @@ -22,7 +22,7 @@ const criticRoute = { describe("agent persona assignment", () => { it("snapshots the default authority and resolved route", () => { - const result = buildBuiltInAgentPersonaAssignment({ resolution: criticRoute }); + const result = buildAgentPersonaAssignment({ resolution: criticRoute }); assert.equal(result.status, "assigned"); if (result.status !== "assigned") return; assert.deepEqual(result.assignment, { @@ -36,7 +36,7 @@ describe("agent persona assignment", () => { }); it("blocks Critic Fix Mode on a provider that cannot enforce workspace authority", () => { - const result = buildBuiltInAgentPersonaAssignment({ + const result = buildAgentPersonaAssignment({ resolution: criticRoute, authorityPolicy: "critic-fix", }); @@ -49,7 +49,7 @@ describe("agent persona assignment", () => { }); it("rejects an authority policy outside the persona contract", () => { - const result = buildBuiltInAgentPersonaAssignment({ + const result = buildAgentPersonaAssignment({ resolution: criticRoute, authorityPolicy: "workspace-write", }); @@ -63,7 +63,7 @@ describe("agent persona assignment", () => { it("rejects forged assignments that combine a persona with elevated authority", () => { assert.equal( - validateBuiltInAgentPersonaAssignment({ + validateAgentPersonaAssignment({ personaId: "scout", definitionVersion: 1, authorityPolicy: "publish-only", @@ -80,9 +80,9 @@ describe("agent persona assignment", () => { }); it("accepts a server-built assignment that matches the declared route", () => { - const result = buildBuiltInAgentPersonaAssignment({ resolution: criticRoute }); + const result = buildAgentPersonaAssignment({ resolution: criticRoute }); assert.equal(result.status, "assigned"); if (result.status !== "assigned") return; - assert.isUndefined(validateBuiltInAgentPersonaAssignment(result.assignment)); + assert.isUndefined(validateAgentPersonaAssignment(result.assignment)); }); }); diff --git a/apps/server/src/j5/agents/agentPersonaAssignment.ts b/apps/server/src/j5/agents/agentPersonaAssignment.ts index f9c4c0c47c2fd..71f1379e5464a 100644 --- a/apps/server/src/j5/agents/agentPersonaAssignment.ts +++ b/apps/server/src/j5/agents/agentPersonaAssignment.ts @@ -6,11 +6,12 @@ import { getBuiltInAgentPersona, type AgentAuthorityPolicyId, type AgentPersonaId, + type AgentPersonaDefinition, } from "./agentPersonas.ts"; type AvailableAgentPersonaRoute = Extract; -export type BuiltInAgentPersonaAssignmentResult = +export type AgentPersonaAssignmentResult = | { readonly status: "assigned"; readonly assignment: OrchestrationV2AgentPersonaAssignment; @@ -28,11 +29,12 @@ export type BuiltInAgentPersonaAssignmentResult = readonly driver: AvailableAgentPersonaRoute["driver"]; }; -export function buildBuiltInAgentPersonaAssignment(input: { +export function buildAgentPersonaAssignment(input: { readonly resolution: AvailableAgentPersonaRoute; + readonly definition?: AgentPersonaDefinition; readonly authorityPolicy?: AgentAuthorityPolicyId; -}): BuiltInAgentPersonaAssignmentResult { - const definition = getBuiltInAgentPersona(input.resolution.personaId); +}): AgentPersonaAssignmentResult { + const definition = input.definition ?? getBuiltInAgentPersona(input.resolution.personaId); const authorityPolicy = input.authorityPolicy ?? definition.authority.defaultPolicy; if (!definition.authority.allowedPolicies.some((policy) => policy === authorityPolicy)) { return { @@ -64,10 +66,10 @@ export function buildBuiltInAgentPersonaAssignment(input: { }; } -export function validateBuiltInAgentPersonaAssignment( +export function validateAgentPersonaAssignment( assignment: OrchestrationV2AgentPersonaAssignment, + definition: AgentPersonaDefinition = getBuiltInAgentPersona(assignment.personaId), ): string | undefined { - const definition = getBuiltInAgentPersona(assignment.personaId); const target = definition.modelRoute[assignment.resolvedRoute === "primary" ? 0 : 1]; const optionId = target.driver === "codex" ? "reasoningEffort" : "effort"; const selectedEffort = assignment.resolvedModelSelection.options?.find( diff --git a/apps/server/src/j5/agents/agentPersonaLaunch.ts b/apps/server/src/j5/agents/agentPersonaLaunch.ts new file mode 100644 index 0000000000000..1e80a6dc035de --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaLaunch.ts @@ -0,0 +1,59 @@ +import type { OrchestrationV2AgentPersonaRequest, ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { buildAgentPersonaAssignment } from "./agentPersonaAssignment.ts"; +import { AgentPersonaLibraryError, type createAgentPersonaLibrary } from "./agentPersonaLibrary.ts"; +import { resolveAgentPersonaRoute, unavailableAgentPersonaReason } from "./agentPersonaRouting.ts"; + +/** Resolve once and save the exact definition before the ordinary durable thread-creation command. */ +export const prepareAgentPersonaLaunch = Effect.fn("prepareAgentPersonaLaunch")(function* ( + request: OrchestrationV2AgentPersonaRequest, + providers: ReadonlyArray, + library: ReturnType, +) { + const catalog = yield* library.catalog(); + if (catalog.disabledIds.includes(request.personaId)) + return yield* new AgentPersonaLibraryError({ + message: "Agent persona is disabled in this environment.", + }); + const definition = catalog.definitions.find(({ id }) => id === request.personaId); + if (definition === undefined) + return yield* new AgentPersonaLibraryError({ + message: "Unknown agent persona in this environment.", + }); + if ( + request.authorityPolicy !== undefined && + !definition.authority.allowedPolicies.includes(request.authorityPolicy) + ) { + return yield* new AgentPersonaLibraryError({ + message: `Authority policy ${request.authorityPolicy} is not allowed for ${request.personaId}.`, + }); + } + const authority = + request.authorityPolicy === undefined ? {} : { authorityPolicy: request.authorityPolicy }; + const resolution = resolveAgentPersonaRoute({ + personaId: request.personaId, + ...authority, + definition, + providers, + }); + if (resolution.status === "unavailable") { + return yield* new AgentPersonaLibraryError({ + message: + unavailableAgentPersonaReason(resolution) === "authority-not-enforceable" + ? `Agent persona ${request.personaId} is blocked because neither route can enforce its authority policy.` + : `Agent persona ${request.personaId} is blocked because its primary and fallback models are unavailable.`, + }); + } + const result = buildAgentPersonaAssignment({ ...authority, definition, resolution }); + if (result.status !== "assigned") + return yield* new AgentPersonaLibraryError({ + message: "The selected persona authority is unsupported.", + }); + const definitionDigest = yield* library.snapshot(definition); + return { + ...result.assignment, + ...(definitionDigest === undefined + ? {} + : { definitionDigest, displayName: definition.displayName }), + }; +}); diff --git a/apps/server/src/j5/agents/agentPersonaLibrary.test.ts b/apps/server/src/j5/agents/agentPersonaLibrary.test.ts new file mode 100644 index 0000000000000..666a5c0730495 --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaLibrary.test.ts @@ -0,0 +1,856 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + type AgentPersonaEditInput, + AgentPersonaImportConflictError, + ProviderDriverKind, + ProviderInstanceId, + type OrchestrationV2AgentPersonaAssignment, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { stringify as yaml } from "yaml"; + +import { prepareAgentPersonaLaunch } from "./agentPersonaLaunch.ts"; +import { definitionDigest, createAgentPersonaLibrary } from "./agentPersonaLibrary.ts"; +import { BUILT_IN_AGENT_PERSONAS, decodeAgentPersonaDefinition } from "./agentPersonas.ts"; +import { validateAgentPersonaAssignment } from "./agentPersonaAssignment.ts"; +import { resolveAgentPersonaRuntime } from "./agentPersonaRuntime.ts"; + +const isImportConflict = Schema.is(AgentPersonaImportConflictError); + +const json = (value: unknown) => JSON.stringify(value); + +const custom = decodeAgentPersonaDefinition({ + ...BUILT_IN_AGENT_PERSONAS.scout, + id: "team-researcher", + displayName: "Team Researcher", + version: 3, + artifacts: ["TeamBrief"], + outputArtifact: "TeamBrief", + instructions: "# Identity\nTeam researcher.\n# Operating principles\nCite your evidence.", +}); + +const fixture = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "j5-persona-library-" }); + const folder = path.join(stateDir, "personas"); + const library = createAgentPersonaLibrary({ fs, path, stateDir }); + const write = (name: string, value: unknown) => + fs + .makeDirectory(folder, { recursive: true }) + .pipe(Effect.andThen(fs.writeFileString(path.join(folder, name), json(value)))); + return { fs, path, stateDir, folder, library, write }; +}); + +const assignmentFor = (digest: string | undefined): OrchestrationV2AgentPersonaAssignment => ({ + personaId: custom.id, + definitionVersion: custom.version, + displayName: custom.displayName, + ...(digest === undefined ? {} : { definitionDigest: digest }), + authorityPolicy: "read-only", + resolvedRoute: "primary", + resolvedDriver: ProviderDriverKind.make("codex"), + resolvedModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: custom.modelRoute[0].model, + options: [{ id: "reasoningEffort", value: "high" }], + }, +}); + +describe("folder-backed persona library", () => { + it.effect("loads examples only when no library has been configured", () => + Effect.gen(function* () { + const { library, fs, folder, stateDir, path } = yield* fixture; + assert.lengthOf(yield* library.load(), 11); + yield* fs.makeDirectory(folder); + assert.deepEqual(yield* library.load(), []); + yield* fs.writeFileString(path.join(stateDir, "agent-personas.json"), json({ folders: [] })); + assert.deepEqual(yield* library.load(), []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("loads arbitrary persona ids and custom artifacts without adding built-ins", () => + Effect.gen(function* () { + const { library, write } = yield* fixture; + yield* write("researcher.json", custom); + assert.deepEqual(yield* library.load(), [custom]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("reads relative and absolute source folders in deterministic order", () => + Effect.gen(function* () { + const { library, fs, path, stateDir, folder, write } = yield* fixture; + yield* write("z.json", custom); + const second = path.join(stateDir, "team-library"); + yield* fs.makeDirectory(second); + yield* fs.writeFileString(path.join(second, "a.json"), json({ ...custom, id: "another" })); + yield* fs.writeFileString( + path.join(stateDir, "agent-personas.json"), + json({ folders: ["personas", second] }), + ); + assert.deepEqual( + (yield* library.load()).map(({ id }) => id), + [custom.id, "another"], + ); + yield* fs.writeFileString(path.join(folder, "README.md"), "Not a definition"); + assert.lengthOf(yield* library.load(), 2); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects duplicate ids across files instead of choosing an arbitrary winner", () => + Effect.gen(function* () { + const { library, write } = yield* fixture; + yield* write("a.json", custom); + yield* write("b.json", { ...custom, version: 4 }); + assert.include(String(yield* library.load().pipe(Effect.flip)), "Duplicate persona id"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("reports invalid files, invalid configuration, and missing configured folders", () => + Effect.gen(function* () { + const { library, write, fs, path, stateDir } = yield* fixture; + yield* write("invalid.json", { ...custom, outputArtifact: "UndefinedArtifact" }); + assert.include(String(yield* library.load().pipe(Effect.flip)), "Invalid persona file"); + yield* fs.writeFileString(path.join(stateDir, "agent-personas.json"), "not json"); + assert.isDefined(yield* library.load().pipe(Effect.flip)); + yield* fs.writeFileString( + path.join(stateDir, "agent-personas.json"), + json({ folders: ["missing"] }), + ); + assert.isDefined(yield* library.load().pipe(Effect.flip)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect( + "resumes from the snapshot after source edits and removal, including same-version edits", + () => + Effect.gen(function* () { + const { library, write, fs, folder } = yield* fixture; + yield* write("researcher.json", custom); + const assignment = assignmentFor(yield* library.snapshot(custom)); + assert.isDefined(assignment.definitionDigest); + yield* write("researcher.json", { ...custom, instructions: "Changed behavior" }); + assert.equal((yield* library.load())[0]?.instructions, "Changed behavior"); + assert.deepEqual(yield* library.readSnapshot(assignment), custom); + yield* fs.remove(folder, { recursive: true }); + const freshLibrary = createAgentPersonaLibrary({ + fs, + path: yield* Path.Path, + stateDir: (yield* Path.Path).dirname(folder), + }); + const restored = yield* freshLibrary.readSnapshot(assignment); + assert.isUndefined(validateAgentPersonaAssignment(assignment, restored)); + const policy = yield* resolveAgentPersonaRuntime( + { agentPersonaAssignment: assignment, runtimeMode: "full-access" }, + freshLibrary, + ); + assert.include( + "agentPersonaInstructions" in policy ? policy.agentPersonaInstructions : "", + custom.instructions, + ); + assert.notInclude( + "agentPersonaInstructions" in policy ? policy.agentPersonaInstructions : "", + "Changed behavior", + ); + assert.equal(policy.runtimeMode, "approval-required"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("refuses a missing or modified snapshot instead of adopting current source", () => + Effect.gen(function* () { + const { library, fs, path, stateDir } = yield* fixture; + const assignment = assignmentFor(yield* library.snapshot(custom)); + const file = path.join( + stateDir, + "agent-persona-snapshots", + `${assignment.definitionDigest}.json`, + ); + yield* fs.writeFileString(file, json({ ...custom, instructions: "Replaced" })); + assert.include( + String(yield* library.readSnapshot(assignment).pipe(Effect.flip)), + "does not match", + ); + yield* fs.remove(file); + assert.isDefined(yield* library.readSnapshot(assignment).pipe(Effect.flip)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it("validates structured references, identifiers, and authority choices", () => { + assert.throws(() => decodeAgentPersonaDefinition({ ...custom, id: "../escape" })); + assert.throws(() => decodeAgentPersonaDefinition({ ...custom, inputArtifacts: ["Unknown"] })); + assert.throws(() => + decodeAgentPersonaDefinition({ + ...custom, + authority: { defaultPolicy: "workspace-write", allowedPolicies: ["read-only"] }, + }), + ); + assert.equal( + decodeAgentPersonaDefinition({ + ...custom, + acceptedInput: "A prompt plus repository evidence", + }).id, + custom.id, + ); + }); +}); + +describe("imported persona library", () => { + const file = (definition: typeof custom, name = "agent.json") => ({ + name, + content: json(definition), + }); + + it.effect("imports a nested folder batch and retains it across library instances", () => + Effect.gen(function* () { + const { library, fs, path, stateDir } = yield* fixture; + const other = { ...custom, id: "second-agent" }; + const result = yield* library.importFiles({ + files: [file(custom, "team/researcher/agent.json"), file(other, "team/second/agent.json")], + replaceExisting: false, + }); + assert.deepEqual(result.importedIds, [custom.id, other.id]); + const restarted = createAgentPersonaLibrary({ fs, path, stateDir }); + const catalog = yield* restarted.catalog(); + assert.deepEqual(catalog.importedIds, result.importedIds); + assert.lengthOf(catalog.definitions, 13); + assert.deepEqual( + catalog.definitions.find(({ id }) => id === custom.id), + custom, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects a mixed valid/invalid batch without importing any of it", () => + Effect.gen(function* () { + const { library } = yield* fixture; + yield* library.importFiles({ files: [file(custom)], replaceExisting: false }); + const before = yield* library.load(); + const error = yield* library + .importFiles({ + files: [ + file({ ...custom, id: "valid-new-agent" }), + { name: "broken/agent.json", content: "{}" }, + ], + replaceExisting: false, + }) + .pipe(Effect.flip); + assert.include(String(error), "broken/agent.json"); + assert.deepEqual(yield* library.load(), before); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("requires explicit replacement and restores the source on removal", () => + Effect.gen(function* () { + const { library, write } = yield* fixture; + yield* write("agent.json", custom); + const changed = { ...custom, instructions: "Updated instructions" }; + const input = { files: [file(changed)], replaceExisting: false }; + assert.include(String(yield* library.importFiles(input).pipe(Effect.flip)), "already exist"); + yield* library.importFiles({ ...input, replaceExisting: true }); + assert.deepEqual(yield* library.load(), [changed]); + yield* library.removeImported(custom.id); + assert.deepEqual(yield* library.load(), [custom]); + assert.deepEqual((yield* library.catalog()).importedIds, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects duplicate selected IDs, oversize UTF-8, and empty batches atomically", () => + Effect.gen(function* () { + const { library } = yield* fixture; + for (const files of [ + [file(custom, "a/agent.json"), file(custom, "b/agent.json")], + [{ name: "large.json", content: "é".repeat(32769) }], + [], + Array.from({ length: 51 }, (_, i) => file({ ...custom, id: `agent-${i}` })), + ]) { + yield* library.importFiles({ files, replaceExisting: true }).pipe(Effect.flip); + assert.deepEqual((yield* library.catalog()).importedIds, []); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("preserves snapshots through import replacement and removal", () => + Effect.gen(function* () { + const { library } = yield* fixture; + yield* library.importFiles({ files: [file(custom)], replaceExisting: false }); + const assignment = assignmentFor(yield* library.snapshot(custom)); + yield* library.importFiles({ + files: [file({ ...custom, instructions: "New instructions" })], + replaceExisting: true, + }); + yield* library.removeImported(custom.id); + assert.isFalse((yield* library.load()).some(({ id }) => id === custom.id)); + assert.deepEqual(yield* library.readSnapshot(assignment), custom); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("persists disabled imports, rejects new launches, and preserves saved tasks", () => + Effect.gen(function* () { + const { library, fs, path, stateDir } = yield* fixture; + yield* library.importFiles({ files: [file(custom)], replaceExisting: false }); + const assignment = assignmentFor(yield* library.snapshot(custom)); + yield* library.setImportedEnabled(custom.id, false); + const restarted = createAgentPersonaLibrary({ fs, path, stateDir }); + assert.deepEqual((yield* restarted.catalog()).disabledIds, [custom.id]); + assert.isTrue((yield* restarted.catalog()).definitions.some(({ id }) => id === custom.id)); + assert.isFalse((yield* restarted.load()).some(({ id }) => id === custom.id)); + const error = yield* prepareAgentPersonaLaunch({ personaId: custom.id }, [], restarted).pipe( + Effect.flip, + ); + assert.include(String(error), "disabled"); + assert.deepEqual(yield* restarted.readSnapshot(assignment), custom); + yield* restarted.setImportedEnabled(custom.id, true); + assert.deepEqual((yield* restarted.catalog()).disabledIds, []); + assert.deepEqual( + (yield* restarted.load()).find(({ id }) => id === custom.id), + custom, + ); + const enabledError = yield* prepareAgentPersonaLaunch( + { personaId: custom.id }, + [], + restarted, + ).pipe(Effect.flip); + assert.notInclude(String(enabledError), "disabled"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("preserves disabled state on replacement and clears it on removal", () => + Effect.gen(function* () { + const { library, write } = yield* fixture; + yield* write("source.json", custom); + yield* library.importFiles({ files: [file(custom)], replaceExisting: true }); + yield* library.setImportedEnabled(custom.id, false); + yield* library.importFiles({ + files: [file({ ...custom, instructions: "Replacement" })], + replaceExisting: true, + }); + assert.deepEqual((yield* library.catalog()).disabledIds, [custom.id]); + assert.deepEqual(yield* library.load(), []); + yield* library + .importFiles({ files: [file(custom)], replaceExisting: false }) + .pipe(Effect.flip); + yield* library.removeImported(custom.id); + assert.deepEqual((yield* library.catalog()).disabledIds, []); + assert.deepEqual(yield* library.load(), [custom]); + yield* library.setImportedEnabled(custom.id, false).pipe(Effect.flip); + yield* library.importFiles({ files: [file(custom)], replaceExisting: true }); + assert.deepEqual((yield* library.catalog()).disabledIds, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("treats previously imported definitions without a toggle field as enabled", () => + Effect.gen(function* () { + const { library, fs, path, stateDir } = yield* fixture; + yield* fs.writeFileString( + path.join(stateDir, "imported-agent-personas.json"), + json([custom]), + ); + assert.deepEqual((yield* library.catalog()).disabledIds, []); + assert.deepEqual( + (yield* library.load()).find(({ id }) => id === custom.id), + custom, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("serializes imports from different sessions without losing either batch", () => + Effect.gen(function* () { + const { library, fs, path, stateDir } = yield* fixture; + const secondSession = createAgentPersonaLibrary({ fs, path, stateDir }); + yield* Effect.all( + [ + library.importFiles({ files: [file(custom)], replaceExisting: false }), + secondSession.importFiles({ + files: [file({ ...custom, id: "other-agent" })], + replaceExisting: false, + }), + ], + { concurrency: "unbounded" }, + ); + assert.deepEqual((yield* library.catalog()).importedIds.toSorted(), [ + "other-agent", + custom.id, + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); + +describe("removing folder-loaded agents", () => { + it.effect( + "removes a source agent durably without deleting its file or snapshot, and allows reimport", + () => + Effect.gen(function* () { + const { library, write, fs, path, stateDir, folder } = yield* fixture; + const scout = { ...custom, displayName: "My Local Scout" }; + yield* write("local-scout.json", scout); + yield* write("other.json", { ...custom, id: "other-agent" }); + const assignment = { + ...assignmentFor(yield* library.snapshot(scout)), + displayName: scout.displayName, + }; + yield* library.removeSource(scout.id); + const restarted = createAgentPersonaLibrary({ fs, path, stateDir }); + assert.deepEqual( + (yield* restarted.load()).map(({ id }) => id), + ["other-agent"], + ); + assert.equal(yield* fs.readFileString(path.join(folder, "local-scout.json")), json(scout)); + const error = yield* prepareAgentPersonaLaunch({ personaId: scout.id }, [], restarted).pipe( + Effect.flip, + ); + assert.include(String(error), "Unknown agent persona"); + assert.deepEqual(yield* restarted.readSnapshot(assignment), scout); + yield* restarted.importFiles({ + files: [{ name: "local-scout.json", content: json(scout) }], + replaceExisting: false, + }); + assert.isTrue((yield* restarted.load()).some(({ id }) => id === scout.id)); + yield* restarted.removeImported(scout.id); + assert.isFalse((yield* restarted.load()).some(({ id }) => id === scout.id)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect( + "serializes source removals across sessions and refuses stale source actions on imports", + () => + Effect.gen(function* () { + const { library, fs, path, stateDir } = yield* fixture; + const second = createAgentPersonaLibrary({ fs, path, stateDir }); + yield* Effect.all([library.removeSource("scout"), second.removeSource("builder")], { + concurrency: "unbounded", + }); + const remaining = (yield* library.load()).map(({ id }) => id); + assert.notInclude(remaining, "scout"); + assert.notInclude(remaining, "builder"); + yield* library.importFiles({ + files: [{ name: "agent.json", content: json(custom) }], + replaceExisting: false, + }); + assert.include( + String(yield* library.removeSource(custom.id).pipe(Effect.flip)), + "Remove import", + ); + assert.isTrue((yield* library.load()).some(({ id }) => id === custom.id)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); + +describe("complete agent removal", () => { + it.effect( + "removes source agents, imports, and disabled overrides without revealing a fallback", + () => + Effect.gen(function* () { + for (const mode of ["source", "import", "override"] as const) { + const { library, write, fs, path, stateDir, folder } = yield* fixture; + if (mode !== "import") yield* write("agent.json", custom); + if (mode !== "source") { + yield* library.importFiles({ + files: [{ name: "agent.json", content: json(custom) }], + replaceExisting: true, + }); + yield* library.setImportedEnabled(custom.id, false); + } + const assignment = assignmentFor(yield* library.snapshot(custom)); + yield* library.removeAgent(custom.id); + const restarted = createAgentPersonaLibrary({ fs, path, stateDir }); + const catalog = yield* restarted.catalog(); + assert.isFalse(catalog.definitions.some(({ id }) => id === custom.id)); + assert.notInclude(catalog.importedIds, custom.id); + assert.notInclude(catalog.disabledIds, custom.id); + assert.deepEqual(yield* restarted.readSnapshot(assignment), custom); + if (mode !== "import") + assert.equal(yield* fs.readFileString(path.join(folder, "agent.json")), json(custom)); + yield* restarted.removeAgent(custom.id); + yield* restarted.importFiles({ + files: [{ name: "agent.json", content: json(custom) }], + replaceExisting: false, + }); + assert.isTrue((yield* restarted.load()).some(({ id }) => id === custom.id)); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); + +describe("editing imported agents", () => { + const edit = (definition = custom): AgentPersonaEditInput => ({ + personaId: definition.id, + expectedDigest: definitionDigest(definition), + displayName: definition.displayName, + description: definition.description, + authorityPolicy: definition.authority.defaultPolicy, + modelRoute: definition.modelRoute, + }); + + it.effect( + "updates only the imported copy and preserves source, instructions, snapshots, and disabled state", + () => + Effect.gen(function* () { + const { library, write, fs, path, stateDir, folder } = yield* fixture; + yield* write("agent.json", custom); + yield* library.importFiles({ + files: [{ name: "agent.json", content: json(custom) }], + replaceExisting: true, + }); + const assignment = assignmentFor(yield* library.snapshot(custom)); + yield* library.setImportedEnabled(custom.id, false); + const update: AgentPersonaEditInput = { + ...edit(), + displayName: "Edited Researcher", + description: "Edited description", + authorityPolicy: "workspace-write", + modelRoute: [ + { driver: "codex", model: "team-model", reasoningEffort: "medium" }, + custom.modelRoute[1], + ], + }; + yield* library.editImported(update); + const restarted = createAgentPersonaLibrary({ fs, path, stateDir }); + const catalog = yield* restarted.catalog(); + const changed = catalog.definitions.find(({ id }) => id === custom.id)!; + assert.deepEqual(changed, { + ...custom, + displayName: update.displayName, + description: update.description, + version: custom.version + 1, + authority: { defaultPolicy: "workspace-write", allowedPolicies: ["workspace-write"] }, + modelRoute: update.modelRoute, + }); + assert.include(catalog.disabledIds, custom.id); + assert.equal(yield* fs.readFileString(path.join(folder, "agent.json")), json(custom)); + assert.deepEqual(yield* restarted.readSnapshot(assignment), custom); + assert.notEqual(definitionDigest(changed), assignment.definitionDigest); + yield* restarted.setImportedEnabled(custom.id, true); + assert.deepEqual( + (yield* restarted.load()).find(({ id }) => id === custom.id), + changed, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect( + "rejects stale edits, removed imports, invalid details, and oversized definitions without changing stored data", + () => + Effect.gen(function* () { + const { library } = yield* fixture; + yield* library.importFiles({ + files: [{ name: "agent.json", content: json(custom) }], + replaceExisting: false, + }); + for (const input of [ + { ...edit(), displayName: " " }, + { ...edit(), description: "x".repeat(65536) }, + ]) { + yield* library.editImported(input).pipe(Effect.flip); + assert.deepEqual( + (yield* library.load()).find(({ id }) => id === custom.id), + custom, + ); + } + yield* library.editImported({ ...edit(), displayName: "First edit" }); + assert.include( + String( + yield* library.editImported({ ...edit(), displayName: "Stale edit" }).pipe(Effect.flip), + ), + "changed in another session", + ); + assert.equal( + (yield* library.load()).find(({ id }) => id === custom.id)?.displayName, + "First edit", + ); + yield* library.removeAgent(custom.id); + assert.include( + String(yield* library.editImported(edit()).pipe(Effect.flip)), + "no longer exists", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect( + "requires an imported copy and retains allowed policy choices when the default is unchanged", + () => + Effect.gen(function* () { + const { library, write } = yield* fixture; + const definition = decodeAgentPersonaDefinition({ + ...custom, + authority: { + defaultPolicy: "read-only", + allowedPolicies: ["read-only", "workspace-write"], + }, + }); + yield* write("agent.json", definition); + yield* library.editImported(edit(definition)).pipe(Effect.flip); + yield* library.importFiles({ + files: [{ name: "agent.json", content: json(definition) }], + replaceExisting: true, + }); + yield* library.editImported({ ...edit(definition), displayName: "New name" }); + assert.deepEqual((yield* library.load())[0]?.authority, definition.authority); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); + +describe("confirmed import replacement", () => { + it.effect("reports conflicts before writing the batch and replaces only after confirmation", () => + Effect.gen(function* () { + const { library } = yield* fixture; + yield* library.importFiles({ + files: [{ name: "agent.json", content: json(custom) }], + replaceExisting: false, + }); + yield* library.setImportedEnabled(custom.id, false); + const assignment = assignmentFor(yield* library.snapshot(custom)); + const replacement = { ...custom, version: custom.version + 1, displayName: "Company update" }; + const files = [ + { name: "updated/agent.json", content: json(replacement) }, + { name: "new/agent.json", content: json({ ...custom, id: "new-agent" }) }, + ]; + const conflict = yield* library + .importFiles({ files, replaceExisting: false }) + .pipe(Effect.flip); + if (!isImportConflict(conflict)) return yield* Effect.die("Expected import conflicts"); + assert.deepEqual(conflict.conflicts, [ + { + personaId: custom.id, + displayName: custom.displayName, + definitionDigest: definitionDigest(custom), + }, + ]); + assert.deepEqual( + (yield* library.catalog()).definitions.find(({ id }) => id === custom.id), + custom, + ); + assert.isFalse((yield* library.catalog()).definitions.some(({ id }) => id === "new-agent")); + yield* library.importFiles({ + files, + replaceExisting: true, + confirmedConflicts: conflict.conflicts, + }); + const updated = yield* library.catalog(); + assert.deepEqual( + updated.definitions.find(({ id }) => id === custom.id), + replacement, + ); + assert.isTrue(updated.definitions.some(({ id }) => id === "new-agent")); + assert.include(updated.disabledIds, custom.id); + assert.deepEqual(yield* library.readSnapshot(assignment), custom); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("requires fresh confirmation after an edit or a new conflicting ID appears", () => + Effect.gen(function* () { + const { library } = yield* fixture; + yield* library.importFiles({ + files: [{ name: "agent.json", content: json(custom) }], + replaceExisting: false, + }); + const files = [ + { name: "agent.json", content: json({ ...custom, version: 10 }) }, + { name: "new.json", content: json({ ...custom, id: "new-agent" }) }, + ]; + const original = yield* library + .importFiles({ files, replaceExisting: false }) + .pipe(Effect.flip); + if (!isImportConflict(original)) return yield* Effect.die("Expected import conflicts"); + yield* library.editImported({ + personaId: custom.id, + expectedDigest: definitionDigest(custom), + displayName: "Concurrent edit", + description: custom.description, + authorityPolicy: custom.authority.defaultPolicy, + modelRoute: custom.modelRoute, + }); + yield* library.importFiles({ + files: [ + { + name: "new.json", + content: json({ ...custom, id: "new-agent", displayName: "Another import" }), + }, + ], + replaceExisting: false, + }); + const stale = yield* library + .importFiles({ files, replaceExisting: true, confirmedConflicts: original.conflicts }) + .pipe(Effect.flip); + if (!isImportConflict(stale)) return yield* Effect.die("Expected updated conflicts"); + assert.lengthOf(stale.conflicts, 2); + assert.equal( + (yield* library.catalog()).definitions.find(({ id }) => id === custom.id)?.displayName, + "Concurrent edit", + ); + yield* library.importFiles({ + files, + replaceExisting: true, + confirmedConflicts: stale.conflicts, + }); + assert.equal( + (yield* library.catalog()).definitions.find(({ id }) => id === custom.id)?.version, + 10, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); + +describe("selective import replacement", () => { + it.effect("keeps skipped definitions and imports selected replacements and new agents", () => + Effect.gen(function* () { + const { library } = yield* fixture; + const kept = { ...custom, id: "keep-agent", displayName: "Local edits" }; + yield* library.importFiles({ + files: [custom, kept].map((value) => ({ + name: `${value.id}/agent.json`, + content: json(value), + })), + replaceExisting: false, + }); + yield* library.setImportedEnabled(kept.id, false); + const files = [ + { ...custom, version: 10 }, + { ...kept, version: 10, displayName: "Company update" }, + { ...custom, id: "new-agent" }, + ].map((value) => ({ name: `${value.id}/agent.json`, content: json(value) })); + const conflict = yield* library + .importFiles({ files, replaceExisting: false }) + .pipe(Effect.flip); + if (!isImportConflict(conflict)) return yield* Effect.die("Expected conflicts"); + const result = yield* library.importFiles({ + files, + replaceExisting: true, + confirmedConflicts: conflict.conflicts.filter(({ personaId }) => personaId === custom.id), + skippedPersonaIds: [kept.id], + }); + assert.deepEqual(result.importedIds, [custom.id, "new-agent"]); + const catalog = yield* library.catalog(); + assert.deepEqual( + catalog.definitions.find(({ id }) => id === kept.id), + kept, + ); + assert.include(catalog.disabledIds, kept.id); + assert.equal(catalog.definitions.find(({ id }) => id === custom.id)?.version, 10); + assert.isTrue(catalog.definitions.some(({ id }) => id === "new-agent")); + const allSkipped = yield* library.importFiles({ + files, + replaceExisting: true, + confirmedConflicts: [], + skippedPersonaIds: [custom.id, kept.id, "new-agent"], + }); + assert.deepEqual(allSkipped.importedIds, []); + assert.deepEqual(yield* library.catalog(), catalog); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("does not restore a skipped agent removed while confirmation was open", () => + Effect.gen(function* () { + const { library } = yield* fixture; + const files = [{ name: "agent.json", content: json(custom) }]; + yield* library.importFiles({ files, replaceExisting: false }); + yield* library.removeAgent(custom.id); + const result = yield* library.importFiles({ + files, + replaceExisting: true, + confirmedConflicts: [], + skippedPersonaIds: [custom.id], + }); + assert.deepEqual(result.importedIds, []); + assert.isFalse((yield* library.catalog()).definitions.some(({ id }) => id === custom.id)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); + +describe("YAML agent definitions", () => { + it.effect( + "imports YAML and JSON together and preserves literal instructions in JSON storage and snapshots", + () => + Effect.gen(function* () { + const { library, fs, path, stateDir } = yield* fixture; + const instructions = '# Researcher\n\nCite "file paths".\nDo not edit files.'; + const { instructions: _instructions, ...fields } = custom; + const content = + yaml(fields) + + 'instructions: |-\n # Researcher\n\n Cite "file paths".\n Do not edit files.\n'; + const result = yield* library.importFiles({ + files: [ + { name: "team/agent.YAML", content }, + { name: "team/other.json", content: json({ ...custom, id: "other" }) }, + { name: "team/third.yml", content: yaml({ ...custom, id: "third" }) }, + ], + replaceExisting: false, + }); + assert.deepEqual(result.importedIds, [custom.id, "other", "third"]); + const definition = (yield* library.load()).find(({ id }) => id === custom.id)!; + assert.deepEqual(definition, { ...custom, instructions }); + const snapshot = assignmentFor(yield* library.snapshot(definition)); + assert.deepEqual(yield* library.readSnapshot(snapshot), definition); + const stored = yield* fs.readFileString( + path.join(stateDir, "imported-agent-personas.json"), + ); + assert.isTrue(stored.trimStart().startsWith("[")); + const conflict = yield* library + .importFiles({ + files: [{ name: "agent.json", content: json(definition) }], + replaceExisting: false, + }) + .pipe(Effect.flip); + if (!isImportConflict(conflict)) return yield* Effect.die("Expected conflict"); + assert.equal(conflict.conflicts[0]?.definitionDigest, definitionDigest(definition)); + yield* library.importFiles({ + files: [{ name: "agent.yml", content: yaml({ ...definition, version: 4 }) }], + replaceExisting: true, + confirmedConflicts: conflict.conflicts, + }); + assert.equal((yield* library.load()).find(({ id }) => id === custom.id)?.version, 4); + assert.deepEqual(yield* library.readSnapshot(snapshot), definition); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("loads YAML source folders and rejects duplicate IDs across formats", () => + Effect.gen(function* () { + const { library, fs, path, folder, write } = yield* fixture; + yield* fs.makeDirectory(folder, { recursive: true }); + yield* fs.writeFileString(path.join(folder, "agent.yml"), yaml(custom)); + assert.deepEqual(yield* library.load(), [custom]); + yield* write("agent.json", custom); + assert.include(String(yield* library.load().pipe(Effect.flip)), "Duplicate persona id"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects malformed or invalid YAML and duplicate IDs before writing any files", () => + Effect.gen(function* () { + const { library } = yield* fixture; + for (const content of [ + "id: [broken", + "id: one\nid: two", + yaml({ ...custom, version: "one" }), + yaml({ ...custom, outputArtifact: "UnknownArtifact" }), + yaml(custom) + "\n---\n" + yaml(custom), + "id: !custom researcher", + "id: &name researcher\ndisplayName: *name", + ]) { + const error = yield* library + .importFiles({ + files: [ + { name: "valid.json", content: json({ ...custom, id: "valid" }) }, + { name: "invalid.yaml", content }, + ], + replaceExisting: false, + }) + .pipe(Effect.flip); + assert.include(String(error), "invalid.yaml"); + assert.isFalse((yield* library.load()).some(({ id }) => id === "valid")); + } + const duplicate = yield* library + .importFiles({ + files: [ + { name: "agent.json", content: json(custom) }, + { name: "agent.yaml", content: yaml(custom) }, + ], + replaceExisting: false, + }) + .pipe(Effect.flip); + assert.include(String(duplicate), "Multiple selected files"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/j5/agents/agentPersonaLibrary.ts b/apps/server/src/j5/agents/agentPersonaLibrary.ts new file mode 100644 index 0000000000000..7f994f8ad7866 --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaLibrary.ts @@ -0,0 +1,448 @@ +import * as NodeCrypto from "node:crypto"; +import { + AgentPersonaId, + isAgentPersonaDefinitionFile, + AgentPersonaImportConflictError, + AGENT_PERSONA_IMPORT_MAX_BYTES, + AGENT_PERSONA_IMPORT_MAX_FILES, + type AgentPersonaImportInput, + type AgentPersonaEditInput, + type OrchestrationV2AgentPersonaAssignment, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { parseDocument } from "yaml"; + +import { writeFileStringAtomically } from "../../atomicWrite.ts"; +import { ServerConfig } from "../../config.ts"; +import { + decodeAgentPersonaDefinition, + getBuiltInAgentPersona, + listBuiltInAgentPersonas, + AgentPersonaDefinition, +} from "./agentPersonas.ts"; + +export class AgentPersonaLibraryError extends Schema.TaggedErrorClass()( + "AgentPersonaLibraryError", + { message: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) {} + +const decodeDefinitionJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(AgentPersonaDefinition), +); +const decodeDefinition = Effect.fn("AgentPersonaLibrary.decodeDefinition")(function* ( + text: string, + name = "agent.json", +) { + const definition = /\.ya?ml$/i.test(name) + ? yield* Effect.try((): unknown => { + const document = parseDocument(text, { version: "1.2", uniqueKeys: true }); + const issue = document.errors[0] ?? document.warnings[0]; + if (issue) throw issue; + return document.toJS({ maxAliasCount: 0 }); + }) + : yield* decodeDefinitionJson(text); + return yield* Effect.try(() => decodeAgentPersonaDefinition(definition)); +}); +const ImportedDefinition = Schema.Struct({ + ...AgentPersonaDefinition.fields, + enabled: Schema.optional(Schema.Boolean), +}); +const ImportedDefinitions = Schema.Array(ImportedDefinition); +const decodeImports = Schema.decodeUnknownEffect(Schema.fromJsonString(ImportedDefinitions)); +const encodeImports = Schema.encodeEffect(Schema.fromJsonString(ImportedDefinitions)); +const RemovedSourceIds = Schema.Array(AgentPersonaId); +const decodeRemovedSourceIds = Schema.decodeUnknownEffect(Schema.fromJsonString(RemovedSourceIds)); +const encodeRemovedSourceIds = Schema.encodeEffect(Schema.fromJsonString(RemovedSourceIds)); +// RPC layers are session-local; serialize library mutations across all sessions in this process. +const importPermit = Semaphore.makeUnsafe(1); +const encodeDefinition = Schema.encodeEffect(Schema.fromJsonString(AgentPersonaDefinition)); +const LibraryConfig = Schema.Struct({ + folders: Schema.Array(Schema.String.check(Schema.isMinLength(1))), +}); +const decodeLibraryConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(LibraryConfig)); +export const definitionDigest = (definition: AgentPersonaDefinition): string => + NodeCrypto.createHash("sha256").update(JSON.stringify(definition)).digest("hex"); + +/** Source folders are read on catalog requests and launches; running tasks use immutable snapshots. */ +export function createAgentPersonaLibrary(storage?: { + readonly stateDir: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; +}) { + const loadSources = Effect.fn("AgentPersonaLibrary.loadSources")(function* () { + if (storage === undefined) return listBuiltInAgentPersonas(); + const { stateDir, fs, path } = storage; + const configText = yield* fs.readFileString(path.join(stateDir, "agent-personas.json")).pipe( + Effect.map(Option.some), + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), + ), + ); + const explicitlyConfigured = Option.isSome(configText); + const folders = Option.isSome(configText) + ? (yield* decodeLibraryConfig(configText.value)).folders.map((folder) => + path.resolve(stateDir, folder), + ) + : [path.join(stateDir, "personas")]; + const definitions = new Map(); + for (const folder of folders) { + const entries = yield* fs.readDirectory(folder).pipe( + Effect.map(Option.some), + Effect.catch((error) => + !explicitlyConfigured && error.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail(error), + ), + ); + if (Option.isNone(entries)) return listBuiltInAgentPersonas(); + for (const name of [...entries.value].sort()) { + if (!isAgentPersonaDefinitionFile(name)) continue; + const file = path.join(folder, name); + const stat = yield* fs.stat(file); + if (stat.type !== "File") continue; + if (Number(stat.size) > 65536) + return yield* new AgentPersonaLibraryError({ + message: `Persona file exceeds 64 KiB: ${file}`, + }); + const text = yield* fs.readFileString(file); + const definition = yield* decodeDefinition(text, name).pipe( + Effect.mapError( + (cause) => + new AgentPersonaLibraryError({ message: `Invalid persona file ${file}`, cause }), + ), + ); + if (definitions.has(definition.id)) + return yield* new AgentPersonaLibraryError({ + message: `Duplicate persona id: ${definition.id}`, + }); + definitions.set(definition.id, definition); + } + } + return [...definitions.values()]; + }); + + const readImports = Effect.fn("AgentPersonaLibrary.readImports")(function* () { + if (storage === undefined) return []; + const text = yield* storage.fs + .readFileString(storage.path.join(storage.stateDir, "imported-agent-personas.json")) + .pipe( + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed("[]") : Effect.fail(error), + ), + ); + const definitions = yield* decodeImports(text); + const ids = new Set(); + for (const definition of definitions) { + yield* Effect.try(() => decodeAgentPersonaDefinition(definition)); + if (ids.has(definition.id)) + return yield* new AgentPersonaLibraryError({ + message: `Duplicate imported persona id: ${definition.id}`, + }); + ids.add(definition.id); + } + return definitions; + }); + + const readRemovedSourceIds = Effect.fn("AgentPersonaLibrary.readRemovedSourceIds")(function* () { + if (storage === undefined) return []; + const text = yield* storage.fs + .readFileString(storage.path.join(storage.stateDir, "removed-source-agent-personas.json")) + .pipe( + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed("[]") : Effect.fail(error), + ), + ); + return yield* decodeRemovedSourceIds(text); + }); + + const catalog = Effect.fn("AgentPersonaLibrary.catalog")(function* () { + const sources = yield* loadSources(); + const imported = yield* readImports(); + const removed = new Set(yield* readRemovedSourceIds()); + const definitions = new Map( + sources.filter(({ id }) => !removed.has(id)).map((definition) => [definition.id, definition]), + ); + for (const { enabled: _enabled, ...definition } of imported) + definitions.set(definition.id, definition); + return { + definitions: [...definitions.values()], + importedIds: imported.map(({ id }) => id), + disabledIds: imported.filter(({ enabled }) => enabled === false).map(({ id }) => id), + }; + }); + const load = Effect.fn("AgentPersonaLibrary.load")(function* () { + const library = yield* catalog(); + const disabled = new Set(library.disabledIds); + return library.definitions.filter(({ id }) => !disabled.has(id)); + }); + + const writeImports = Effect.fn("AgentPersonaLibrary.writeImports")(function* ( + definitions: ReadonlyArray, + ) { + if (storage === undefined) + return yield* new AgentPersonaLibraryError({ + message: "Persona import storage is unavailable.", + }); + yield* writeFileStringAtomically({ + filePath: storage.path.join(storage.stateDir, "imported-agent-personas.json"), + contents: yield* encodeImports(definitions), + }).pipe( + Effect.provideService(FileSystem.FileSystem, storage.fs), + Effect.provideService(Path.Path, storage.path), + ); + }); + + const importFiles = Effect.fn("AgentPersonaLibrary.importFiles")(function* ( + input: AgentPersonaImportInput, + ) { + if (input.files.length === 0 || input.files.length > AGENT_PERSONA_IMPORT_MAX_FILES) + return yield* new AgentPersonaLibraryError({ + message: `Select between 1 and ${AGENT_PERSONA_IMPORT_MAX_FILES} JSON or YAML files.`, + }); + const incoming = new Map(); + for (const file of input.files) { + if (!isAgentPersonaDefinitionFile(file.name)) + return yield* new AgentPersonaLibraryError({ + message: `Unsupported agent file: ${file.name}`, + }); + if (new TextEncoder().encode(file.content).byteLength > AGENT_PERSONA_IMPORT_MAX_BYTES) + return yield* new AgentPersonaLibraryError({ message: `${file.name} exceeds 64 KiB.` }); + const definition = yield* decodeDefinition(file.content, file.name).pipe( + Effect.mapError( + (cause) => + new AgentPersonaLibraryError({ + message: `Invalid agent definition: ${file.name}`, + cause, + }), + ), + ); + if (incoming.has(definition.id)) + return yield* new AgentPersonaLibraryError({ + message: `Multiple selected files use the id "${definition.id}". Each agent needs a unique id.`, + }); + incoming.set(definition.id, definition); + } + for (const id of input.skippedPersonaIds ?? []) incoming.delete(id); + if (incoming.size === 0) return { importedIds: [] }; + const existing = (yield* catalog()).definitions; + const conflicts = existing + .filter(({ id }) => incoming.has(id)) + .map((definition) => ({ + personaId: definition.id, + displayName: definition.displayName, + definitionDigest: definitionDigest(definition), + })); + const confirmationChanged = + input.confirmedConflicts !== undefined && + conflicts.some( + (conflict) => + !input.confirmedConflicts?.some( + (confirmed) => + confirmed.personaId === conflict.personaId && + confirmed.definitionDigest === conflict.definitionDigest, + ), + ); + if (conflicts.length > 0 && (!input.replaceExisting || confirmationChanged)) + return yield* new AgentPersonaImportConflictError({ + message: confirmationChanged + ? "Existing agents changed. Review the replacement again." + : "Agents with these IDs already exist.", + conflicts, + }); + const imported = new Map( + (yield* readImports()).map((definition) => [definition.id, definition]), + ); + for (const [id, definition] of incoming) + imported.set(id, { + ...definition, + enabled: imported.get(id)?.enabled ?? true, + }); + yield* writeImports([...imported.values()]); + return { importedIds: [...incoming.keys()] }; + }, importPermit.withPermit); + + const editImported = Effect.fn("AgentPersonaLibrary.editImported")(function* ( + input: AgentPersonaEditInput, + ) { + const imported = yield* readImports(); + const current = imported.find(({ id }) => id === input.personaId); + if (current === undefined) + return yield* new AgentPersonaLibraryError({ + message: "Imported agent no longer exists. Refresh the library.", + }); + const original = yield* Effect.try(() => decodeAgentPersonaDefinition(current)); + if (definitionDigest(original) !== input.expectedDigest) + return yield* new AgentPersonaLibraryError({ + message: + "This agent changed in another session. Close the editor, refresh the library, and try again.", + }); + const updated = yield* Effect.try(() => + decodeAgentPersonaDefinition({ + ...original, + version: original.version + 1, + displayName: input.displayName, + description: input.description, + authority: + input.authorityPolicy === original.authority.defaultPolicy + ? original.authority + : { + defaultPolicy: input.authorityPolicy, + allowedPolicies: [input.authorityPolicy], + }, + modelRoute: input.modelRoute, + }), + ).pipe( + Effect.mapError( + (cause) => new AgentPersonaLibraryError({ message: "Invalid agent details.", cause }), + ), + ); + const encoded = yield* encodeDefinition(updated); + if (new TextEncoder().encode(encoded).byteLength > AGENT_PERSONA_IMPORT_MAX_BYTES) + return yield* new AgentPersonaLibraryError({ message: "Agent definition exceeds 64 KiB." }); + yield* writeImports( + imported.map((definition) => + definition.id === input.personaId + ? { + ...updated, + enabled: current.enabled ?? true, + } + : definition, + ), + ); + }, importPermit.withPermit); + + const setImportedEnabled = Effect.fn("AgentPersonaLibrary.setImportedEnabled")(function* ( + id: string, + enabled: boolean, + ) { + const imported = yield* readImports(); + if (!imported.some((definition) => definition.id === id)) + return yield* new AgentPersonaLibraryError({ + message: "Imported agent no longer exists in this environment.", + }); + yield* writeImports( + imported.map((definition) => + definition.id === id ? { ...definition, enabled } : definition, + ), + ); + }, importPermit.withPermit); + + const removeImported = Effect.fn("AgentPersonaLibrary.removeImported")(function* (id: string) { + const imported = yield* readImports(); + yield* writeImports(imported.filter((definition) => definition.id !== id)); + }, importPermit.withPermit); + + const excludeSource = Effect.fn("AgentPersonaLibrary.excludeSource")(function* (id: string) { + if (storage === undefined) + return yield* new AgentPersonaLibraryError({ + message: "Persona library storage is unavailable.", + }); + const removed = new Set(yield* readRemovedSourceIds()); + removed.add(id); + yield* writeFileStringAtomically({ + filePath: storage.path.join(storage.stateDir, "removed-source-agent-personas.json"), + contents: yield* encodeRemovedSourceIds([...removed]), + }).pipe( + Effect.provideService(FileSystem.FileSystem, storage.fs), + Effect.provideService(Path.Path, storage.path), + ); + }); + + const removeSource = Effect.fn("AgentPersonaLibrary.removeSource")(function* (id: string) { + if ((yield* readImports()).some((definition) => definition.id === id)) + return yield* new AgentPersonaLibraryError({ + message: "This agent was imported. Refresh the library and use Remove import.", + }); + yield* excludeSource(id); + }, importPermit.withPermit); + + const removeAgent = Effect.fn("AgentPersonaLibrary.removeAgent")(function* (id: string) { + const imported = yield* readImports(); + // Persist source exclusion first so deleting an override cannot reveal its source again. + yield* excludeSource(id); + if (imported.some((definition) => definition.id === id)) + yield* writeImports(imported.filter((definition) => definition.id !== id)); + }, importPermit.withPermit); + + const snapshot = Effect.fn("AgentPersonaLibrary.snapshot")(function* ( + definition: AgentPersonaDefinition, + ) { + if (storage === undefined) return undefined; + const { fs, path, stateDir } = storage; + const digest = definitionDigest(definition); + yield* writeFileStringAtomically({ + filePath: path.join(stateDir, "agent-persona-snapshots", `${digest}.json`), + contents: yield* encodeDefinition(definition), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + return digest; + }); + + const readSnapshot = Effect.fn("AgentPersonaLibrary.readSnapshot")(function* ( + assignment: OrchestrationV2AgentPersonaAssignment, + ) { + if (assignment.definitionDigest === undefined) { + const definition = yield* Effect.try(() => getBuiltInAgentPersona(assignment.personaId)); + if (definition.version !== assignment.definitionVersion) + return yield* new AgentPersonaLibraryError({ message: "Unknown legacy persona version." }); + return definition; + } + if (storage === undefined) + return yield* new AgentPersonaLibraryError({ + message: "Persona snapshot storage is unavailable.", + }); + const digest = assignment.definitionDigest; + if (!/^[a-f0-9]{64}$/.test(digest)) + return yield* new AgentPersonaLibraryError({ message: "Invalid persona snapshot digest." }); + const text = yield* storage.fs.readFileString( + storage.path.join(storage.stateDir, "agent-persona-snapshots", `${digest}.json`), + ); + const definition = yield* decodeDefinition(text); + if ( + definitionDigest(definition) !== digest || + definition.id !== assignment.personaId || + definition.version !== assignment.definitionVersion || + definition.displayName !== assignment.displayName + ) { + return yield* new AgentPersonaLibraryError({ + message: "Persona snapshot does not match its assignment.", + }); + } + return definition; + }); + + return { + load, + catalog, + importFiles, + editImported, + setImportedEnabled, + removeImported, + removeSource, + removeAgent, + snapshot, + readSnapshot, + }; +} + +/** Capture the environment at service construction. Pure unit layers can use the bundled examples. */ +export const makeAgentPersonaLibrary = Effect.gen(function* () { + const config = yield* Effect.serviceOption(ServerConfig); + if (Option.isNone(config)) return createAgentPersonaLibrary(); + const fs = yield* Effect.serviceOption(FileSystem.FileSystem); + const path = yield* Effect.serviceOption(Path.Path); + if (Option.isNone(fs) || Option.isNone(path)) + return yield* Effect.die(new Error("Persona library requires filesystem and path services.")); + return createAgentPersonaLibrary({ + stateDir: config.value.stateDir, + fs: fs.value, + path: path.value, + }); +}); diff --git a/apps/server/src/j5/agents/agentPersonaPrompts.ts b/apps/server/src/j5/agents/agentPersonaPrompts.ts index abfad4a66de12..a60c77271ad96 100644 --- a/apps/server/src/j5/agents/agentPersonaPrompts.ts +++ b/apps/server/src/j5/agents/agentPersonaPrompts.ts @@ -1,39 +1,11 @@ -import type { BuiltInAgentPersonaId } from "@t3tools/contracts"; +import type { AgentPersonaId } from "@t3tools/contracts"; +import { BUILT_IN_AGENT_PERSONAS } from "./agentPersonas.ts"; -export const BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1 = ` -# Builder - -You implement one approved Plan Handoff, Diagnosis Handoff, or Review Inbox in the current workspace. When the input is a Review Inbox, apply the validated findings from Critic and Sentry. Return a Code Complete Handoff when the work is ready for review. - -## Operating contract - -- Treat the governing handoff as the scope contract. Inspect repository instructions and current state before editing. -- Make the smallest complete implementation that satisfies the handoff. Add or update focused tests when behavior changes. -- Validate the changed behavior with the narrowest relevant tests, checks, or builds. Distinguish source failures from environment or harness failures. -- For Critic and Sentry findings, record each finding as applied, refuted with evidence, or unresolved. Do not silently skip a finding or expand beyond it. -- Preserve unrelated user changes. Stop and report a conflict when the requested work cannot be completed safely within the handoff. - -## Authority boundary - -- You may edit workspace files and run build, test, lint, formatting, and inspection commands needed for the implementation. -- Never commit or push. Never open, update, approve, close, or merge a pull request. Do not perform those actions even when the handoff asks for them. -- Do not claim publication or review approval. A Publisher owns publication; Critic and Sentry own independent review. - -## Required output: Code Complete Handoff - -Return a concise Code Complete Handoff containing: - -1. The governing handoff and implemented scope. -2. Changed paths and a behavior summary. -3. Critic and Sentry finding dispositions, when provided. -4. Tests and checks run with their results. -5. Known limitations, residual risks, and unverified areas. -6. The review-ready diff identity when available. -7. An explicit statement that no commit or push was performed. -`; +/** Legacy assignments keep the original bundled Builder prompt. New assignments use snapshots. */ +export const BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1 = BUILT_IN_AGENT_PERSONAS.builder.instructions; export function getBuiltInAgentPersonaInstructions(input: { - readonly personaId: BuiltInAgentPersonaId; + readonly personaId: AgentPersonaId; readonly definitionVersion: number; }): string | undefined { return input.personaId === "builder" && input.definitionVersion === 1 diff --git a/apps/server/src/j5/agents/agentPersonaProviderPolicy.ts b/apps/server/src/j5/agents/agentPersonaProviderPolicy.ts index 6fdb1a06d5090..ec860f5b24422 100644 --- a/apps/server/src/j5/agents/agentPersonaProviderPolicy.ts +++ b/apps/server/src/j5/agents/agentPersonaProviderPolicy.ts @@ -30,6 +30,7 @@ const WORKSPACE_WRITE_POLICY = { sandboxPolicy: { type: "workspaceWrite", networkAccess: false }, } as const satisfies AgentPersonaProviderPolicy; +/** Tests the supported sandbox/tool policy, not prose promises such as no-commit or targeted edits. */ export function providerCanEnforceAgentPersonaAuthority( driver: string, authorityPolicy: AgentPersonaAuthorityPolicy, diff --git a/apps/server/src/j5/agents/agentPersonaRouting.test.ts b/apps/server/src/j5/agents/agentPersonaRouting.test.ts index 53059d61ebddf..94d9c7fda816a 100644 --- a/apps/server/src/j5/agents/agentPersonaRouting.test.ts +++ b/apps/server/src/j5/agents/agentPersonaRouting.test.ts @@ -6,10 +6,7 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; -import { - buildBuiltInAgentPersonaCatalog, - resolveBuiltInAgentPersonaRoute, -} from "./agentPersonaRouting.ts"; +import { buildAgentPersonaCatalog, resolveAgentPersonaRoute } from "./agentPersonaRouting.ts"; import { providerCanEnforceAgentPersonaAuthority } from "./agentPersonaProviderPolicy.ts"; import { listBuiltInAgentPersonas, type AgentModelTarget } from "./agentPersonas.ts"; @@ -81,7 +78,7 @@ describe("agent persona routing", () => { it("uses every enforceable persona's declared primary route when it is available", () => { for (const definition of listBuiltInAgentPersonas()) { const [primary, fallback] = definition.modelRoute; - const resolution = resolveBuiltInAgentPersonaRoute({ + const resolution = resolveAgentPersonaRoute({ personaId: definition.id, providers: [providerForTarget(fallback), providerForTarget(primary)], }); @@ -101,7 +98,7 @@ describe("agent persona routing", () => { it("uses only an enforceable declared fallback after its primary is unavailable", () => { for (const definition of listBuiltInAgentPersonas()) { const [primary, fallback] = definition.modelRoute; - const resolution = resolveBuiltInAgentPersonaRoute({ + const resolution = resolveAgentPersonaRoute({ personaId: definition.id, providers: [providerForTarget(primary, { enabled: false }), providerForTarget(fallback)], }); @@ -125,7 +122,7 @@ describe("agent persona routing", () => { it("skips Claude and uses the Codex fallback for Critic Fix Mode", () => { const definition = listBuiltInAgentPersonas().find(({ id }) => id === "critic")!; const [primary, fallback] = definition.modelRoute; - const resolution = resolveBuiltInAgentPersonaRoute({ + const resolution = resolveAgentPersonaRoute({ personaId: "critic", authorityPolicy: "critic-fix", providers: [providerForTarget(primary), providerForTarget(fallback)], @@ -149,7 +146,7 @@ describe("agent persona routing", () => { personaId === "builder" ? [providerForTarget(primary, { enabled: false }), providerForTarget(fallback)] : [providerForTarget(primary), providerForTarget(fallback)]; - const resolution = resolveBuiltInAgentPersonaRoute({ personaId, providers }); + const resolution = resolveAgentPersonaRoute({ personaId, providers }); assert.equal(resolution.status, "unavailable", personaId); if (resolution.status === "available") continue; @@ -164,7 +161,7 @@ describe("agent persona routing", () => { it("blocks every persona when both declared routes are unavailable", () => { for (const definition of listBuiltInAgentPersonas()) { - const resolution = resolveBuiltInAgentPersonaRoute({ + const resolution = resolveAgentPersonaRoute({ personaId: definition.id, providers: [], }); @@ -187,7 +184,7 @@ describe("agent persona routing", () => { }); it("selects the primary provider, exact model, and provider-specific effort option", () => { - const resolution = resolveBuiltInAgentPersonaRoute({ + const resolution = resolveAgentPersonaRoute({ personaId: "scout", providers: [ provider({ @@ -214,7 +211,7 @@ describe("agent persona routing", () => { }); it("uses fallback only after recording why the primary is ineligible", () => { - const resolution = resolveBuiltInAgentPersonaRoute({ + const resolution = resolveAgentPersonaRoute({ personaId: "skeptic", providers: [ provider({ @@ -255,7 +252,7 @@ describe("agent persona routing", () => { it("prefers the default instance before configured custom instances", () => { const terra = model("gpt-5.6-terra", "reasoningEffort"); - const resolution = resolveBuiltInAgentPersonaRoute({ + const resolution = resolveAgentPersonaRoute({ personaId: "scout", providers: [ provider({ instanceId: "codex_work", driver: "codex", models: [terra] }), @@ -269,7 +266,7 @@ describe("agent persona routing", () => { }); it("fails closed when neither target advertises the exact model and effort", () => { - const resolution = resolveBuiltInAgentPersonaRoute({ + const resolution = resolveAgentPersonaRoute({ personaId: "skeptic", providers: [ provider({ @@ -294,7 +291,7 @@ describe("agent persona routing", () => { }); it("builds the ordered presentation catalog with environment-specific availability", () => { - const catalog = buildBuiltInAgentPersonaCatalog([ + const catalog = buildAgentPersonaCatalog([ provider({ instanceId: "codex", driver: "codex", @@ -327,3 +324,20 @@ describe("agent persona routing", () => { ); }); }); + +it("presents imported definitions without transmitting their instruction bodies", () => { + const definition = { + ...listBuiltInAgentPersonas()[0]!, + id: "team-researcher", + displayName: "Team Researcher", + }; + const catalog = buildAgentPersonaCatalog( + [providerForTarget(definition.modelRoute[0])], + [definition], + ); + assert.lengthOf(catalog.personas, 1); + assert.equal(catalog.personas[0]?.personaId, "team-researcher"); + assert.equal(catalog.personas[0]?.availability.status, "available"); + assert.notProperty(catalog.personas[0], "instructions"); + assert.deepEqual(buildAgentPersonaCatalog([], []), { personas: [] }); +}); diff --git a/apps/server/src/j5/agents/agentPersonaRouting.ts b/apps/server/src/j5/agents/agentPersonaRouting.ts index 097ba64a2c94b..b4d93b8a18062 100644 --- a/apps/server/src/j5/agents/agentPersonaRouting.ts +++ b/apps/server/src/j5/agents/agentPersonaRouting.ts @@ -13,6 +13,7 @@ import { getBuiltInAgentPersona, listBuiltInAgentPersonas, type AgentModelTarget, + type AgentPersonaDefinition, type AgentPersonaId, } from "./agentPersonas.ts"; import { providerCanEnforceAgentPersonaAuthority } from "./agentPersonaProviderPolicy.ts"; @@ -43,7 +44,7 @@ export type AgentPersonaRouteResolution = | { readonly status: "available"; readonly personaId: AgentPersonaId; - readonly definitionVersion: 1; + readonly definitionVersion: number; readonly route: "primary" | "fallback"; readonly driver: AgentModelTarget["driver"]; readonly modelSelection: ModelSelection; @@ -52,7 +53,7 @@ export type AgentPersonaRouteResolution = | { readonly status: "unavailable"; readonly personaId: AgentPersonaId; - readonly definitionVersion: 1; + readonly definitionVersion: number; readonly attempts: ReadonlyArray; }; @@ -109,12 +110,13 @@ function candidatesForTarget( .map(({ provider }) => provider); } -export function resolveBuiltInAgentPersonaRoute(input: { +export function resolveAgentPersonaRoute(input: { readonly personaId: AgentPersonaId; + readonly definition?: AgentPersonaDefinition; readonly providers: ReadonlyArray; readonly authorityPolicy?: AgentPersonaAuthorityPolicy; }): AgentPersonaRouteResolution { - const definition = getBuiltInAgentPersona(input.personaId); + const definition = input.definition ?? getBuiltInAgentPersona(input.personaId); const authorityPolicy = input.authorityPolicy ?? definition.authority.defaultPolicy; const rejectedTargets: Array = []; @@ -170,13 +172,15 @@ export function resolveBuiltInAgentPersonaRoute(input: { }; } -export function buildBuiltInAgentPersonaCatalog( +export function buildAgentPersonaCatalog( providers: ReadonlyArray, + definitions: ReadonlyArray = listBuiltInAgentPersonas(), ): OrchestrationV2AgentPersonaCatalog { return { - personas: listBuiltInAgentPersonas().map((definition) => { - const resolution = resolveBuiltInAgentPersonaRoute({ + personas: definitions.map((definition) => { + const resolution = resolveAgentPersonaRoute({ personaId: definition.id, + definition, providers, }); return { diff --git a/apps/server/src/j5/agents/agentPersonaRuntime.ts b/apps/server/src/j5/agents/agentPersonaRuntime.ts new file mode 100644 index 0000000000000..5ae5e8e21c869 --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaRuntime.ts @@ -0,0 +1,49 @@ +import * as Effect from "effect/Effect"; +import type { OrchestrationV2AppThread } from "@t3tools/contracts"; +import { getBuiltInAgentPersonaInstructions } from "./agentPersonaPrompts.ts"; +import { AgentPersonaLibraryError, type createAgentPersonaLibrary } from "./agentPersonaLibrary.ts"; +import { getAgentAuthorityRules } from "./agentPersonas.ts"; +import { + providerCanEnforceAgentPersonaAuthority, + translateAgentPersonaProviderPolicy, +} from "./agentPersonaProviderPolicy.ts"; + +/** Persona instructions express behavior; the translated sandbox supplies the actual runtime boundary. */ +export const resolveAgentPersonaRuntime = Effect.fn("resolveAgentPersonaRuntime")(function* ( + thread: Pick, + library: ReturnType, +) { + const assignment = thread.agentPersonaAssignment; + if (assignment === undefined) return { runtimeMode: thread.runtimeMode }; + let instructions: string | undefined; + if (assignment.definitionDigest === undefined) { + instructions = getBuiltInAgentPersonaInstructions(assignment); + } else { + const definition = yield* library.readSnapshot(assignment); + if ( + !definition.authority.allowedPolicies.includes(assignment.authorityPolicy) || + !providerCanEnforceAgentPersonaAuthority( + assignment.resolvedDriver, + assignment.authorityPolicy, + ) + ) { + return yield* new AgentPersonaLibraryError({ + message: "The assigned persona runtime permissions are unsupported.", + }); + } + const rules = getAgentAuthorityRules(assignment.authorityPolicy); + instructions = `${definition.instructions}\n\n## Selected behavior: ${assignment.authorityPolicy}\nThese are operating instructions, not additional sandbox guarantees.\n${ + rules.mayCommit + ? "Commit and push only within the authorized publication scope." + : "Never commit or push." + }\n${rules.mayWritePullRequest ? "Open or update pull requests only within the authorized scope." : "Do not mutate pull requests."}\nNever merge a pull request.\n${ + assignment.authorityPolicy === "critic-fix" + ? "Edit only to address the requested review findings." + : "" + }`; + } + return { + ...translateAgentPersonaProviderPolicy(assignment.authorityPolicy, assignment.resolvedDriver), + ...(instructions === undefined ? {} : { agentPersonaInstructions: instructions }), + }; +}); diff --git a/apps/server/src/j5/agents/agentPersonas.ts b/apps/server/src/j5/agents/agentPersonas.ts index 1546ac4581f7a..283942d91c054 100644 --- a/apps/server/src/j5/agents/agentPersonas.ts +++ b/apps/server/src/j5/agents/agentPersonas.ts @@ -1,15 +1,28 @@ import { + BUILT_IN_AGENT_ARTIFACT_IDS, BUILT_IN_AGENT_PERSONA_IDS, - type AgentPersonaAuthorityPolicy, - type BuiltInAgentArtifactId, - type BuiltInAgentPersonaId, + AgentPersonaAuthorityPolicy, + AgentPersonaId, + AgentPersonaModelTarget, + TrimmedNonEmptyString, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; -export const AGENT_PERSONA_IDS = BUILT_IN_AGENT_PERSONA_IDS; -export type AgentPersonaId = BuiltInAgentPersonaId; - -export type AgentArtifactId = BuiltInAgentArtifactId; +import scout from "./examples/scout.json" with { type: "json" }; +import navigator from "./examples/navigator.json" with { type: "json" }; +import advocate from "./examples/advocate.json" with { type: "json" }; +import skeptic from "./examples/skeptic.json" with { type: "json" }; +import builder from "./examples/builder.json" with { type: "json" }; +import critic from "./examples/critic.json" with { type: "json" }; +import sentry from "./examples/sentry.json" with { type: "json" }; +import publisher from "./examples/publisher.json" with { type: "json" }; +import investigator from "./examples/investigator.json" with { type: "json" }; +import prosecutor from "./examples/prosecutor.json" with { type: "json" }; +import herald from "./examples/herald.json" with { type: "json" }; +export const AGENT_PERSONA_IDS = BUILT_IN_AGENT_PERSONA_IDS; +export type { AgentPersonaId }; +export type AgentArtifactId = string; export type AgentAuthorityPolicyId = AgentPersonaAuthorityPolicy; export interface AgentAuthorityRules { @@ -20,6 +33,7 @@ export interface AgentAuthorityRules { readonly mayMergePullRequest: false; } +/** Behavioral expectations; provider policy translation separately defines enforceable controls. */ export const AGENT_AUTHORITY_RULES = { "read-only": { workspace: "read-only", @@ -65,192 +79,69 @@ export const AGENT_AUTHORITY_RULES = { }, } as const satisfies Record; -export interface AgentModelTarget { - readonly driver: "codex" | "claudeAgent"; - readonly model: string; - readonly reasoningEffort: "medium" | "high"; -} +const NonEmpty = TrimmedNonEmptyString; +const AgentModelTarget = AgentPersonaModelTarget; +export type AgentModelTarget = typeof AgentModelTarget.Type; + +export const AgentPersonaDefinition = Schema.Struct({ + id: AgentPersonaId, + version: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + displayName: NonEmpty, + description: NonEmpty, + acceptedInput: NonEmpty, + artifacts: Schema.optional(Schema.Array(NonEmpty)), + inputArtifacts: Schema.Array(NonEmpty), + outputArtifact: NonEmpty, + authority: Schema.Struct({ + defaultPolicy: AgentPersonaAuthorityPolicy, + allowedPolicies: Schema.Array(AgentPersonaAuthorityPolicy), + }), + modelRoute: Schema.Tuple([AgentModelTarget, AgentModelTarget]), + instructions: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(32768)), +}); +export type AgentPersonaDefinition = typeof AgentPersonaDefinition.Type; -export interface AgentPersonaDefinition { - readonly id: AgentPersonaId; - readonly version: 1; - readonly displayName: string; - readonly description: string; - readonly acceptedInput: string; - readonly inputArtifacts: ReadonlyArray; - readonly outputArtifact: AgentArtifactId; - readonly authority: { - readonly defaultPolicy: AgentAuthorityPolicyId; - readonly allowedPolicies: ReadonlyArray; - }; - readonly modelRoute: readonly [AgentModelTarget, AgentModelTarget]; +const decodeDefinition = Schema.decodeUnknownSync(AgentPersonaDefinition); + +export function decodeAgentPersonaDefinition(value: unknown): AgentPersonaDefinition { + const definition = decodeDefinition(value); + if (!definition.authority.allowedPolicies.includes(definition.authority.defaultPolicy)) { + throw new Error(`Persona ${definition.id}: default authority must be allowed.`); + } + const artifacts = new Set([ + ...BUILT_IN_AGENT_ARTIFACT_IDS, + ...(definition.artifacts ?? []), + ]); + for (const artifact of [...definition.inputArtifacts, definition.outputArtifact]) { + if (!artifacts.has(artifact)) + throw new Error(`Persona ${definition.id}: undefined artifact ${artifact}.`); + } + return definition; } +/** Bundled examples use exactly the same format and validation as imported definitions. */ export const BUILT_IN_AGENT_PERSONAS = { - scout: { - id: "scout", - version: 1, - displayName: "Scout", - description: "Collects cited evidence into a Context Brief. Read-only.", - acceptedInput: "Evidence request or prompt", - inputArtifacts: [], - outputArtifact: "ContextBrief", - authority: { defaultPolicy: "read-only", allowedPolicies: ["read-only"] }, - modelRoute: [ - { driver: "codex", model: "gpt-5.6-terra", reasoningEffort: "high" }, - { driver: "claudeAgent", model: "claude-opus-5", reasoningEffort: "high" }, - ], - }, - navigator: { - id: "navigator", - version: 1, - displayName: "Navigator", - description: "Turns a Context Brief into an implementation plan. Read-only.", - acceptedInput: "ContextBrief", - inputArtifacts: ["ContextBrief"], - outputArtifact: "PlanHandoff", - authority: { defaultPolicy: "read-only", allowedPolicies: ["read-only"] }, - modelRoute: [ - { driver: "codex", model: "gpt-5.6-sol", reasoningEffort: "high" }, - { driver: "claudeAgent", model: "claude-fable-5-1", reasoningEffort: "high" }, - ], - }, - advocate: { - id: "advocate", - version: 1, - displayName: "Advocate", - description: "Checks a plan against product and design requirements.", - acceptedInput: "PlanHandoff plus Jira, Confluence, or Figma evidence", - inputArtifacts: ["PlanHandoff"], - outputArtifact: "PlanCritique", - authority: { defaultPolicy: "read-only", allowedPolicies: ["read-only"] }, - modelRoute: [ - { driver: "claudeAgent", model: "claude-sonnet-5", reasoningEffort: "high" }, - { driver: "codex", model: "gpt-5.6-terra", reasoningEffort: "high" }, - ], - }, - skeptic: { - id: "skeptic", - version: 1, - displayName: "Skeptic", - description: "Stress-tests a plan for feasibility, risk, and hidden scope.", - acceptedInput: "PlanHandoff plus repository evidence", - inputArtifacts: ["PlanHandoff"], - outputArtifact: "PlanCritique", - authority: { defaultPolicy: "read-only", allowedPolicies: ["read-only"] }, - modelRoute: [ - { driver: "claudeAgent", model: "claude-opus-5", reasoningEffort: "high" }, - { driver: "codex", model: "gpt-5.6-terra", reasoningEffort: "high" }, - ], - }, - builder: { - id: "builder", - version: 1, - displayName: "Builder", - description: "Implements an approved handoff. Never commits or pushes.", - acceptedInput: "PlanHandoff, DiagnosisHandoff, or ReviewInbox", - inputArtifacts: ["PlanHandoff", "DiagnosisHandoff", "ReviewInbox"], - outputArtifact: "CodeCompleteHandoff", - authority: { defaultPolicy: "workspace-write", allowedPolicies: ["workspace-write"] }, - modelRoute: [ - { driver: "codex", model: "gpt-5.6-sol", reasoningEffort: "high" }, - { driver: "claudeAgent", model: "claude-opus-5", reasoningEffort: "high" }, - ], - }, - critic: { - id: "critic", - version: 1, - displayName: "Critic", - description: "Reviews implementation; Fix Mode may apply targeted fixes.", - acceptedInput: "CodeCompleteHandoff plus governing handoff and diff", - inputArtifacts: ["CodeCompleteHandoff", "PlanHandoff", "DiagnosisHandoff", "ReviewInbox"], - outputArtifact: "ReviewHandoff", - authority: { - defaultPolicy: "critic-review", - allowedPolicies: ["critic-review", "critic-fix"], - }, - modelRoute: [ - { driver: "claudeAgent", model: "claude-opus-5", reasoningEffort: "high" }, - { driver: "codex", model: "gpt-5.6-terra", reasoningEffort: "high" }, - ], - }, - sentry: { - id: "sentry", - version: 1, - displayName: "Sentry", - description: "Reviews a diff for security, authorization, secrets, and PII risks.", - acceptedInput: "CodeCompleteHandoff plus diff and relevant architecture", - inputArtifacts: ["CodeCompleteHandoff"], - outputArtifact: "ReviewHandoff", - authority: { defaultPolicy: "read-only", allowedPolicies: ["read-only"] }, - modelRoute: [ - { driver: "claudeAgent", model: "claude-fable-5-1", reasoningEffort: "high" }, - { driver: "codex", model: "gpt-5.6-terra", reasoningEffort: "high" }, - ], - }, - publisher: { - id: "publisher", - version: 1, - displayName: "Publisher", - description: "Commits, pushes, and opens or updates a PR. Never merges.", - acceptedInput: "CodeCompleteHandoff plus resolved review findings", - inputArtifacts: ["CodeCompleteHandoff", "ReviewHandoff"], - outputArtifact: "PublicationReceipt", - authority: { defaultPolicy: "publish-only", allowedPolicies: ["publish-only"] }, - modelRoute: [ - { driver: "codex", model: "gpt-5.6-terra", reasoningEffort: "medium" }, - { driver: "claudeAgent", model: "claude-sonnet-5", reasoningEffort: "medium" }, - ], - }, - investigator: { - id: "investigator", - version: 1, - displayName: "Investigator", - description: "Reproduces and diagnoses bugs without landing a fix.", - acceptedInput: "Bug report, Jira issue, or diagnostic prompt", - inputArtifacts: [], - outputArtifact: "DiagnosisHandoff", - authority: { defaultPolicy: "diagnostic", allowedPolicies: ["diagnostic"] }, - modelRoute: [ - { driver: "codex", model: "gpt-5.6-sol", reasoningEffort: "high" }, - { driver: "claudeAgent", model: "claude-fable-5-1", reasoningEffort: "high" }, - ], - }, - prosecutor: { - id: "prosecutor", - version: 1, - displayName: "Prosecutor", - description: "Challenges a diagnosis, its evidence, and proposed repair.", - acceptedInput: "DiagnosisHandoff plus available evidence", - inputArtifacts: ["DiagnosisHandoff"], - outputArtifact: "DiagnosisCritique", - authority: { defaultPolicy: "read-only", allowedPolicies: ["read-only"] }, - modelRoute: [ - { driver: "claudeAgent", model: "claude-opus-5", reasoningEffort: "high" }, - { driver: "codex", model: "gpt-5.6-terra", reasoningEffort: "high" }, - ], - }, - herald: { - id: "herald", - version: 1, - displayName: "Herald", - description: "Reads and classifies GitHub review feedback.", - acceptedInput: "Pull request target and review state", - inputArtifacts: [], - outputArtifact: "ReviewInbox", - authority: { defaultPolicy: "read-only", allowedPolicies: ["read-only"] }, - modelRoute: [ - { driver: "codex", model: "gpt-5.6-terra", reasoningEffort: "high" }, - { driver: "claudeAgent", model: "claude-sonnet-5", reasoningEffort: "high" }, - ], - }, -} as const satisfies Record; + scout: decodeAgentPersonaDefinition(scout), + navigator: decodeAgentPersonaDefinition(navigator), + advocate: decodeAgentPersonaDefinition(advocate), + skeptic: decodeAgentPersonaDefinition(skeptic), + builder: decodeAgentPersonaDefinition(builder), + critic: decodeAgentPersonaDefinition(critic), + sentry: decodeAgentPersonaDefinition(sentry), + publisher: decodeAgentPersonaDefinition(publisher), + investigator: decodeAgentPersonaDefinition(investigator), + prosecutor: decodeAgentPersonaDefinition(prosecutor), + herald: decodeAgentPersonaDefinition(herald), +}; -export const getBuiltInAgentPersona = (id: AgentPersonaId): AgentPersonaDefinition => - BUILT_IN_AGENT_PERSONAS[id]; +export const getBuiltInAgentPersona = (id: string): AgentPersonaDefinition => { + const definition = listBuiltInAgentPersonas().find((candidate) => candidate.id === id); + if (!definition) throw new Error(`Unknown agent persona: ${id}.`); + return definition; +}; export const getAgentAuthorityRules = (id: AgentAuthorityPolicyId): AgentAuthorityRules => AGENT_AUTHORITY_RULES[id]; export const listBuiltInAgentPersonas = (): ReadonlyArray => - AGENT_PERSONA_IDS.map(getBuiltInAgentPersona); + Object.values(BUILT_IN_AGENT_PERSONAS); diff --git a/apps/server/src/j5/agents/examples/advocate.json b/apps/server/src/j5/agents/examples/advocate.json new file mode 100644 index 0000000000000..d8b3826b03ec2 --- /dev/null +++ b/apps/server/src/j5/agents/examples/advocate.json @@ -0,0 +1,26 @@ +{ + "id": "advocate", + "version": 1, + "displayName": "Advocate", + "description": "Checks a plan against product and design requirements.", + "acceptedInput": "PlanHandoff plus Jira, Confluence, or Figma evidence", + "inputArtifacts": ["PlanHandoff"], + "outputArtifact": "PlanCritique", + "authority": { + "defaultPolicy": "read-only", + "allowedPolicies": ["read-only"] + }, + "modelRoute": [ + { + "driver": "claudeAgent", + "model": "claude-sonnet-5", + "reasoningEffort": "high" + }, + { + "driver": "codex", + "model": "gpt-5.6-terra", + "reasoningEffort": "high" + } + ], + "instructions": "# Advocate\n\nChecks a plan against product and design requirements.\n\nAccept: PlanHandoff plus Jira, Confluence, or Figma evidence.\nReturn a PlanCritique with evidence, limitations, and validation results.\nFollow the selected authority policy's behavioral instructions." +} diff --git a/apps/server/src/j5/agents/examples/builder.json b/apps/server/src/j5/agents/examples/builder.json new file mode 100644 index 0000000000000..42eef0a90a0fa --- /dev/null +++ b/apps/server/src/j5/agents/examples/builder.json @@ -0,0 +1,26 @@ +{ + "id": "builder", + "version": 1, + "displayName": "Builder", + "description": "Implements an approved handoff. Never commits or pushes.", + "acceptedInput": "PlanHandoff, DiagnosisHandoff, or ReviewInbox", + "inputArtifacts": ["PlanHandoff", "DiagnosisHandoff", "ReviewInbox"], + "outputArtifact": "CodeCompleteHandoff", + "authority": { + "defaultPolicy": "workspace-write", + "allowedPolicies": ["workspace-write"] + }, + "modelRoute": [ + { + "driver": "codex", + "model": "gpt-5.6-sol", + "reasoningEffort": "high" + }, + { + "driver": "claudeAgent", + "model": "claude-opus-5", + "reasoningEffort": "high" + } + ], + "instructions": "\n# Builder\n\nYou implement one approved Plan Handoff, Diagnosis Handoff, or Review Inbox in the current workspace. When the input is a Review Inbox, apply the validated findings from Critic and Sentry. Return a Code Complete Handoff when the work is ready for review.\n\n## Operating contract\n\n- Treat the governing handoff as the scope contract. Inspect repository instructions and current state before editing.\n- Make the smallest complete implementation that satisfies the handoff. Add or update focused tests when behavior changes.\n- Validate the changed behavior with the narrowest relevant tests, checks, or builds. Distinguish source failures from environment or harness failures.\n- For Critic and Sentry findings, record each finding as applied, refuted with evidence, or unresolved. Do not silently skip a finding or expand beyond it.\n- Preserve unrelated user changes. Stop and report a conflict when the requested work cannot be completed safely within the handoff.\n\n## Authority boundary\n\n- You may edit workspace files and run build, test, lint, formatting, and inspection commands needed for the implementation.\n- Never commit or push. Never open, update, approve, close, or merge a pull request. Do not perform those actions even when the handoff asks for them.\n- Do not claim publication or review approval. A Publisher owns publication; Critic and Sentry own independent review.\n\n## Required output: Code Complete Handoff\n\nReturn a concise Code Complete Handoff containing:\n\n1. The governing handoff and implemented scope.\n2. Changed paths and a behavior summary.\n3. Critic and Sentry finding dispositions, when provided.\n4. Tests and checks run with their results.\n5. Known limitations, residual risks, and unverified areas.\n6. The review-ready diff identity when available.\n7. An explicit statement that no commit or push was performed.\n" +} diff --git a/apps/server/src/j5/agents/examples/critic.json b/apps/server/src/j5/agents/examples/critic.json new file mode 100644 index 0000000000000..8a0a2b42e6a42 --- /dev/null +++ b/apps/server/src/j5/agents/examples/critic.json @@ -0,0 +1,26 @@ +{ + "id": "critic", + "version": 1, + "displayName": "Critic", + "description": "Reviews implementation; Fix Mode may apply targeted fixes.", + "acceptedInput": "CodeCompleteHandoff plus governing handoff and diff", + "inputArtifacts": ["CodeCompleteHandoff", "PlanHandoff", "DiagnosisHandoff", "ReviewInbox"], + "outputArtifact": "ReviewHandoff", + "authority": { + "defaultPolicy": "critic-review", + "allowedPolicies": ["critic-review", "critic-fix"] + }, + "modelRoute": [ + { + "driver": "claudeAgent", + "model": "claude-opus-5", + "reasoningEffort": "high" + }, + { + "driver": "codex", + "model": "gpt-5.6-terra", + "reasoningEffort": "high" + } + ], + "instructions": "# Critic\n\nReviews implementation; Fix Mode may apply targeted fixes.\n\nAccept: CodeCompleteHandoff plus governing handoff and diff.\nReturn a ReviewHandoff with evidence, limitations, and validation results.\nFollow the selected authority policy's behavioral instructions." +} diff --git a/apps/server/src/j5/agents/examples/herald.json b/apps/server/src/j5/agents/examples/herald.json new file mode 100644 index 0000000000000..ed2c214052f61 --- /dev/null +++ b/apps/server/src/j5/agents/examples/herald.json @@ -0,0 +1,26 @@ +{ + "id": "herald", + "version": 1, + "displayName": "Herald", + "description": "Reads and classifies GitHub review feedback.", + "acceptedInput": "Pull request target and review state", + "inputArtifacts": [], + "outputArtifact": "ReviewInbox", + "authority": { + "defaultPolicy": "read-only", + "allowedPolicies": ["read-only"] + }, + "modelRoute": [ + { + "driver": "codex", + "model": "gpt-5.6-terra", + "reasoningEffort": "high" + }, + { + "driver": "claudeAgent", + "model": "claude-sonnet-5", + "reasoningEffort": "high" + } + ], + "instructions": "# Herald\n\nReads and classifies GitHub review feedback.\n\nAccept: Pull request target and review state.\nReturn a ReviewInbox with evidence, limitations, and validation results.\nFollow the selected authority policy's behavioral instructions." +} diff --git a/apps/server/src/j5/agents/examples/investigator.json b/apps/server/src/j5/agents/examples/investigator.json new file mode 100644 index 0000000000000..c8131e757b549 --- /dev/null +++ b/apps/server/src/j5/agents/examples/investigator.json @@ -0,0 +1,26 @@ +{ + "id": "investigator", + "version": 1, + "displayName": "Investigator", + "description": "Reproduces and diagnoses bugs without landing a fix.", + "acceptedInput": "Bug report, Jira issue, or diagnostic prompt", + "inputArtifacts": [], + "outputArtifact": "DiagnosisHandoff", + "authority": { + "defaultPolicy": "diagnostic", + "allowedPolicies": ["diagnostic"] + }, + "modelRoute": [ + { + "driver": "codex", + "model": "gpt-5.6-sol", + "reasoningEffort": "high" + }, + { + "driver": "claudeAgent", + "model": "claude-fable-5-1", + "reasoningEffort": "high" + } + ], + "instructions": "# Investigator\n\nReproduces and diagnoses bugs without landing a fix.\n\nAccept: Bug report, Jira issue, or diagnostic prompt.\nReturn a DiagnosisHandoff with evidence, limitations, and validation results.\nFollow the selected authority policy's behavioral instructions." +} diff --git a/apps/server/src/j5/agents/examples/navigator.json b/apps/server/src/j5/agents/examples/navigator.json new file mode 100644 index 0000000000000..8a69f0b7d5fd8 --- /dev/null +++ b/apps/server/src/j5/agents/examples/navigator.json @@ -0,0 +1,26 @@ +{ + "id": "navigator", + "version": 1, + "displayName": "Navigator", + "description": "Turns a Context Brief into an implementation plan. Read-only.", + "acceptedInput": "ContextBrief", + "inputArtifacts": ["ContextBrief"], + "outputArtifact": "PlanHandoff", + "authority": { + "defaultPolicy": "read-only", + "allowedPolicies": ["read-only"] + }, + "modelRoute": [ + { + "driver": "codex", + "model": "gpt-5.6-sol", + "reasoningEffort": "high" + }, + { + "driver": "claudeAgent", + "model": "claude-fable-5-1", + "reasoningEffort": "high" + } + ], + "instructions": "# Navigator\n\nTurns a Context Brief into an implementation plan. Read-only.\n\nAccept: ContextBrief.\nReturn a PlanHandoff with evidence, limitations, and validation results.\nFollow the selected authority policy's behavioral instructions." +} diff --git a/apps/server/src/j5/agents/examples/prosecutor.json b/apps/server/src/j5/agents/examples/prosecutor.json new file mode 100644 index 0000000000000..b2b6883c6f06e --- /dev/null +++ b/apps/server/src/j5/agents/examples/prosecutor.json @@ -0,0 +1,26 @@ +{ + "id": "prosecutor", + "version": 1, + "displayName": "Prosecutor", + "description": "Challenges a diagnosis, its evidence, and proposed repair.", + "acceptedInput": "DiagnosisHandoff plus available evidence", + "inputArtifacts": ["DiagnosisHandoff"], + "outputArtifact": "DiagnosisCritique", + "authority": { + "defaultPolicy": "read-only", + "allowedPolicies": ["read-only"] + }, + "modelRoute": [ + { + "driver": "claudeAgent", + "model": "claude-opus-5", + "reasoningEffort": "high" + }, + { + "driver": "codex", + "model": "gpt-5.6-terra", + "reasoningEffort": "high" + } + ], + "instructions": "# Prosecutor\n\nChallenges a diagnosis, its evidence, and proposed repair.\n\nAccept: DiagnosisHandoff plus available evidence.\nReturn a DiagnosisCritique with evidence, limitations, and validation results.\nFollow the selected authority policy's behavioral instructions." +} diff --git a/apps/server/src/j5/agents/examples/publisher.json b/apps/server/src/j5/agents/examples/publisher.json new file mode 100644 index 0000000000000..be2c90f0dfb93 --- /dev/null +++ b/apps/server/src/j5/agents/examples/publisher.json @@ -0,0 +1,26 @@ +{ + "id": "publisher", + "version": 1, + "displayName": "Publisher", + "description": "Commits, pushes, and opens or updates a PR. Never merges.", + "acceptedInput": "CodeCompleteHandoff plus resolved review findings", + "inputArtifacts": ["CodeCompleteHandoff", "ReviewHandoff"], + "outputArtifact": "PublicationReceipt", + "authority": { + "defaultPolicy": "publish-only", + "allowedPolicies": ["publish-only"] + }, + "modelRoute": [ + { + "driver": "codex", + "model": "gpt-5.6-terra", + "reasoningEffort": "medium" + }, + { + "driver": "claudeAgent", + "model": "claude-sonnet-5", + "reasoningEffort": "medium" + } + ], + "instructions": "# Publisher\n\nCommits, pushes, and opens or updates a PR. Never merges.\n\nAccept: CodeCompleteHandoff plus resolved review findings.\nReturn a PublicationReceipt with evidence, limitations, and validation results.\nFollow the selected authority policy's behavioral instructions." +} diff --git a/apps/server/src/j5/agents/examples/scout.json b/apps/server/src/j5/agents/examples/scout.json new file mode 100644 index 0000000000000..07263df9b86ac --- /dev/null +++ b/apps/server/src/j5/agents/examples/scout.json @@ -0,0 +1,26 @@ +{ + "id": "scout", + "version": 1, + "displayName": "Scout", + "description": "Collects cited evidence into a Context Brief. Read-only.", + "acceptedInput": "Evidence request or prompt", + "inputArtifacts": [], + "outputArtifact": "ContextBrief", + "authority": { + "defaultPolicy": "read-only", + "allowedPolicies": ["read-only"] + }, + "modelRoute": [ + { + "driver": "codex", + "model": "gpt-5.6-terra", + "reasoningEffort": "high" + }, + { + "driver": "claudeAgent", + "model": "claude-opus-5", + "reasoningEffort": "high" + } + ], + "instructions": "# Scout\n\nCollects cited evidence into a Context Brief. Read-only.\n\nAccept: Evidence request or prompt.\nReturn a ContextBrief with evidence, limitations, and validation results.\nFollow the selected authority policy's behavioral instructions." +} diff --git a/apps/server/src/j5/agents/examples/sentry.json b/apps/server/src/j5/agents/examples/sentry.json new file mode 100644 index 0000000000000..14216f437691d --- /dev/null +++ b/apps/server/src/j5/agents/examples/sentry.json @@ -0,0 +1,26 @@ +{ + "id": "sentry", + "version": 1, + "displayName": "Sentry", + "description": "Reviews a diff for security, authorization, secrets, and PII risks.", + "acceptedInput": "CodeCompleteHandoff plus diff and relevant architecture", + "inputArtifacts": ["CodeCompleteHandoff"], + "outputArtifact": "ReviewHandoff", + "authority": { + "defaultPolicy": "read-only", + "allowedPolicies": ["read-only"] + }, + "modelRoute": [ + { + "driver": "claudeAgent", + "model": "claude-fable-5-1", + "reasoningEffort": "high" + }, + { + "driver": "codex", + "model": "gpt-5.6-terra", + "reasoningEffort": "high" + } + ], + "instructions": "# Sentry\n\nReviews a diff for security, authorization, secrets, and PII risks.\n\nAccept: CodeCompleteHandoff plus diff and relevant architecture.\nReturn a ReviewHandoff with evidence, limitations, and validation results.\nFollow the selected authority policy's behavioral instructions." +} diff --git a/apps/server/src/j5/agents/examples/skeptic.json b/apps/server/src/j5/agents/examples/skeptic.json new file mode 100644 index 0000000000000..046af32d81ec6 --- /dev/null +++ b/apps/server/src/j5/agents/examples/skeptic.json @@ -0,0 +1,26 @@ +{ + "id": "skeptic", + "version": 1, + "displayName": "Skeptic", + "description": "Stress-tests a plan for feasibility, risk, and hidden scope.", + "acceptedInput": "PlanHandoff plus repository evidence", + "inputArtifacts": ["PlanHandoff"], + "outputArtifact": "PlanCritique", + "authority": { + "defaultPolicy": "read-only", + "allowedPolicies": ["read-only"] + }, + "modelRoute": [ + { + "driver": "claudeAgent", + "model": "claude-opus-5", + "reasoningEffort": "high" + }, + { + "driver": "codex", + "model": "gpt-5.6-terra", + "reasoningEffort": "high" + } + ], + "instructions": "# Skeptic\n\nStress-tests a plan for feasibility, risk, and hidden scope.\n\nAccept: PlanHandoff plus repository evidence.\nReturn a PlanCritique with evidence, limitations, and validation results.\nFollow the selected authority policy's behavioral instructions." +} diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index dc950d2829dc1..825c6a528e1f6 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -471,8 +471,10 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { const now = yield* DateTime.now; const threadId = ThreadId.make("thread:foundation-agent-persona"); const assignment = { - personaId: "builder", - definitionVersion: 1, + personaId: "team-builder", + definitionVersion: 3, + definitionDigest: "a".repeat(64), + displayName: "Original Team Builder", authorityPolicy: "workspace-write", resolvedRoute: "primary", resolvedDriver: providerDriver, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1825518091b32..e2ea49dd633db 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -39,7 +39,8 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { validateBuiltInAgentPersonaAssignment } from "../j5/agents/agentPersonaAssignment.ts"; +import { makeAgentPersonaLibrary } from "../j5/agents/agentPersonaLibrary.ts"; +import { validateAgentPersonaAssignment } from "../j5/agents/agentPersonaAssignment.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2 } from "./CommandPolicy.ts"; import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; @@ -539,6 +540,7 @@ function rootProviderThreadsForProvider( } const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(function* () { + const personaLibrary = yield* makeAgentPersonaLibrary; const checkpointService = yield* CheckpointServiceV2; const commandPolicy = yield* CommandPolicyV2; const contextHandoffService = yield* ContextHandoffServiceV2; @@ -1346,7 +1348,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const assignmentError = command.agentPersonaAssignment === undefined ? undefined - : validateBuiltInAgentPersonaAssignment(command.agentPersonaAssignment); + : yield* personaLibrary.readSnapshot(command.agentPersonaAssignment).pipe( + Effect.map((definition) => + validateAgentPersonaAssignment(command.agentPersonaAssignment!, definition), + ), + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ), + ); if (assignmentError !== undefined) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.ts b/apps/server/src/orchestration-v2/RuntimePolicy.ts index 98fb8de0e7daa..8332e4fb2aed0 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.ts @@ -11,8 +11,11 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts"; -import { getBuiltInAgentPersonaInstructions } from "../j5/agents/agentPersonaPrompts.ts"; -import { translateAgentPersonaProviderPolicy } from "../j5/agents/agentPersonaProviderPolicy.ts"; +import { + makeAgentPersonaLibrary, + type createAgentPersonaLibrary, +} from "../j5/agents/agentPersonaLibrary.ts"; +import { resolveAgentPersonaRuntime } from "../j5/agents/agentPersonaRuntime.ts"; import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, @@ -59,43 +62,41 @@ export class RuntimePolicyV2 extends Context.Service, +) => + resolveAgentPersonaRuntime(input.thread, library).pipe( + Effect.map((policy) => + ProviderAdapterV2RuntimePolicy.make({ + ...policy, + interactionMode: input.thread.interactionMode, + cwd: input.cwd, + }), + ), + Effect.mapError( + (cause) => + new RuntimePolicyResolveError({ + projectId: input.thread.projectId, + providerInstanceId: input.thread.providerInstanceId, + cause, + }), + ), + ); /** * IMPLEMENTATIONS */ -export const layer: Layer.Layer = Layer.succeed(RuntimePolicyV2, { - resolve: (input) => - Effect.succeed( - runtimePolicyForThread({ thread: input.thread, cwd: input.thread.worktreePath }), - ), -}); +export const layer: Layer.Layer = Layer.effect( + RuntimePolicyV2, + Effect.gen(function* () { + const library = yield* makeAgentPersonaLibrary; + return RuntimePolicyV2.of({ + resolve: (input) => + runtimePolicyForThread({ thread: input.thread, cwd: input.thread.worktreePath }, library), + }); + }), +); export const layerFromProjectRepository: Layer.Layer< RuntimePolicyV2, @@ -105,6 +106,7 @@ export const layerFromProjectRepository: Layer.Layer< RuntimePolicyV2, Effect.gen(function* () { const projects = yield* ProjectionProjects.ProjectionProjectRepository; + const library = yield* makeAgentPersonaLibrary; return RuntimePolicyV2.of({ resolve: Effect.fn("RuntimePolicyV2.resolve")(function* (input) { const cwd = @@ -132,7 +134,7 @@ export const layerFromProjectRepository: Layer.Layer< }), ), )); - return runtimePolicyForThread({ thread: input.thread, cwd }); + return yield* runtimePolicyForThread({ thread: input.thread, cwd }, library); }), }); }), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index a16ff2058cfc5..b71327f7caca2 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1,3 +1,11 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as ServerConfig from "../config.ts"; +import { BUILT_IN_AGENT_PERSONAS } from "../j5/agents/agentPersonas.ts"; +import { makeAgentPersonaLibrary } from "../j5/agents/agentPersonaLibrary.ts"; +import { resolveAgentPersonaRuntime } from "../j5/agents/agentPersonaRuntime.ts"; + import { assert, it, vi } from "@effect/vitest"; import { CommandId, @@ -1599,3 +1607,122 @@ it.effect("does not depend on the legacy launch workflow table", () => { assert.equal(launched.projection.messages[0]?.text, "No private workflow state"); }).pipe(Effect.provide(harness.layer)); }); + +const jsonPersonaFixture = (value: unknown) => JSON.stringify(value); + +it.effect( + "launches an imported persona and replays its receipt after the source is removed", + () => { + const definition = { + ...BUILT_IN_AGENT_PERSONAS.scout, + id: "team-researcher", + displayName: "Team Researcher", + version: 2, + }; + const harness = makeHarness({ + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-08T00:00:00.000Z", + availability: "available", + slashCommands: [], + skills: [], + models: [ + { + slug: definition.modelRoute[0].model, + name: "Research model", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [{ id: "high", label: "High" }], + }, + ], + }, + }, + ], + }, + ], + }); + const environment = ServerConfig.layerTest("/repo", { prefix: "j5-persona-launch-" }); + return Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const folder = path.join(config.stateDir, "personas"); + yield* fs.makeDirectory(folder, { recursive: true }); + yield* fs.writeFileString(path.join(folder, "team.json"), jsonPersonaFixture(definition)); + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const input = { + ...launchInput({ command: "command:imported-persona", thread: "thread:imported-persona" }), + agentPersona: { personaId: definition.id }, + }; + const launched = yield* launches.launch(input); + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.equal(projection.thread.agentPersonaAssignment?.displayName, definition.displayName); + assert.match( + projection.thread.agentPersonaAssignment?.definitionDigest ?? "", + /^[a-f0-9]{64}$/, + ); + const library = yield* makeAgentPersonaLibrary; + yield* library.importFiles({ + files: [{ name: "agent.json", content: jsonPersonaFixture(definition) }], + replaceExisting: true, + }); + yield* library.setImportedEnabled(definition.id, false); + const disabledError = yield* launches + .launch({ + ...input, + commandId: CommandId.make("command:imported-persona:disabled"), + threadId: ThreadId.make("thread:imported-persona:disabled"), + }) + .pipe(Effect.flip); + assert.equal(disabledError.operation, "resolve-agent-persona"); + assert.include(disabledError.message, "disabled"); + assert.equal((yield* launches.launch(input)).threadId, launched.threadId); + yield* library.setImportedEnabled(definition.id, true); + const reenabled = yield* launches.launch({ + ...input, + commandId: CommandId.make("command:imported-persona:enabled"), + threadId: ThreadId.make("thread:imported-persona:enabled"), + }); + assert.equal( + reenabled.projection.thread.agentPersonaAssignment?.definitionDigest, + projection.thread.agentPersonaAssignment?.definitionDigest, + ); + yield* library.removeImported(definition.id); + yield* fs.remove(folder, { recursive: true }); + const replay = yield* launches.launch(input); + assert.equal(replay.threadId, launched.threadId); + const policy = yield* resolveAgentPersonaRuntime(projection.thread, library); + assert.include( + "agentPersonaInstructions" in policy ? policy.agentPersonaInstructions : "", + definition.instructions, + ); + const error = yield* launches + .launch({ + ...input, + commandId: CommandId.make("command:imported-persona:missing"), + threadId: ThreadId.make("thread:imported-persona:missing"), + }) + .pipe(Effect.flip); + assert.equal(error.operation, "resolve-agent-persona"); + assert.include(error.message, "Unknown agent persona"); + }).pipe( + Effect.provide( + harness.layer.pipe(Layer.provideMerge(environment), Layer.provideMerge(NodeServices.layer)), + ), + Effect.scoped, + ); + }, +); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 5d6a668ee881a..17db07882d612 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -36,12 +36,8 @@ import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.t import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; -import { buildBuiltInAgentPersonaAssignment } from "../j5/agents/agentPersonaAssignment.ts"; -import { getBuiltInAgentPersona } from "../j5/agents/agentPersonas.ts"; -import { - resolveBuiltInAgentPersonaRoute, - unavailableAgentPersonaReason, -} from "../j5/agents/agentPersonaRouting.ts"; +import { prepareAgentPersonaLaunch } from "../j5/agents/agentPersonaLaunch.ts"; +import { makeAgentPersonaLibrary } from "../j5/agents/agentPersonaLibrary.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; import { makeProviderFailure } from "./ProviderFailure.ts"; @@ -149,6 +145,7 @@ function failureDetail(error: unknown): string { } export const make = Effect.gen(function* () { + const personaLibrary = yield* makeAgentPersonaLibrary; const projects = yield* ProjectService.ProjectService; const git = yield* GitWorkflow.GitWorkflowService; const setupScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; @@ -488,63 +485,12 @@ export const make = Effect.gen(function* () { let launchModelSelection = input.modelSelection; let agentPersonaAssignment: OrchestrationV2AgentPersonaAssignment | undefined; if (input.agentPersona !== undefined && Option.isNone(launchReceipt)) { - const definition = getBuiltInAgentPersona(input.agentPersona.personaId); - const requestedAuthorityPolicy = input.agentPersona.authorityPolicy; - if ( - requestedAuthorityPolicy !== undefined && - !definition.authority.allowedPolicies.some( - (policy) => policy === requestedAuthorityPolicy, - ) - ) { - return yield* mapError( - input, - "resolve-agent-persona", - )( - `Authority policy ${requestedAuthorityPolicy} is not allowed for ${input.agentPersona.personaId}.`, - ); - } - const resolution = resolveBuiltInAgentPersonaRoute({ - personaId: input.agentPersona.personaId, - providers: yield* providerRegistry.getProviders, - ...(input.agentPersona.authorityPolicy === undefined - ? {} - : { authorityPolicy: input.agentPersona.authorityPolicy }), - }); - if (resolution.status === "unavailable") { - const reason = unavailableAgentPersonaReason(resolution); - return yield* mapError( - input, - "resolve-agent-persona", - )( - reason === "authority-not-enforceable" - ? `Agent persona ${input.agentPersona.personaId} is blocked because neither route can enforce its authority policy.` - : `Agent persona ${input.agentPersona.personaId} is blocked because its primary and fallback models are unavailable.`, - ); - } - const assignment = buildBuiltInAgentPersonaAssignment({ - resolution, - ...(input.agentPersona.authorityPolicy === undefined - ? {} - : { authorityPolicy: input.agentPersona.authorityPolicy }), - }); - if (assignment.status === "invalid-authority-policy") { - return yield* mapError( - input, - "resolve-agent-persona", - )( - `Authority policy ${assignment.requestedPolicy} is not allowed for ${assignment.personaId}.`, - ); - } - if (assignment.status === "authority-not-enforceable") { - return yield* mapError( - input, - "resolve-agent-persona", - )( - `Agent persona ${assignment.personaId} is blocked because ${assignment.driver} cannot enforce ${assignment.requestedPolicy} authority.`, - ); - } - agentPersonaAssignment = assignment.assignment; - launchModelSelection = assignment.assignment.resolvedModelSelection; + agentPersonaAssignment = yield* prepareAgentPersonaLaunch( + input.agentPersona, + yield* providerRegistry.getProviders, + personaLibrary, + ).pipe(Effect.mapError(mapError(input, "resolve-agent-persona"))); + launchModelSelection = agentPersonaAssignment.resolvedModelSelection; } return yield* Effect.gen(function* () { const candidateThreadId = diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ac6e765f41594..9d07fcdf201c6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,4 @@ +import { AgentPersonaCatalogError, AgentPersonaImportConflictError } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Encoding from "effect/Encoding"; @@ -133,7 +134,8 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; -import { buildBuiltInAgentPersonaCatalog } from "./j5/agents/agentPersonaRouting.ts"; +import { definitionDigest, makeAgentPersonaLibrary } from "./j5/agents/agentPersonaLibrary.ts"; +import { buildAgentPersonaCatalog } from "./j5/agents/agentPersonaRouting.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -200,6 +202,8 @@ const resolveDiscoveryForConfig = ( Effect.map(Option.getOrElse(onTimeout)), ); +const isAgentPersonaImportConflict = Schema.is(AgentPersonaImportConflictError); + export const resolveAvailableEditorsForConfig = ( discovery: Effect.Effect, E, R>, ) => resolveDiscoveryForConfig(discovery, () => []); @@ -627,6 +631,7 @@ const makeWsRpcLayer = ( const providerInstallation = yield* makeProviderInstallation(); const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; + const personaLibrary = yield* makeAgentPersonaLibrary; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; @@ -1385,7 +1390,118 @@ const makeWsRpcLayer = ( [ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog]: (_input) => observeRpcEffect( ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog, - providerRegistry.getProviders.pipe(Effect.map(buildBuiltInAgentPersonaCatalog)), + Effect.gen(function* () { + const library = yield* personaLibrary + .catalog() + .pipe( + Effect.mapError( + (cause) => new AgentPersonaCatalogError({ message: String(cause) }), + ), + ); + const catalog = buildAgentPersonaCatalog( + yield* providerRegistry.getProviders, + library.definitions, + ); + const importedIds = new Set(library.importedIds); + const editable = new Map( + library.definitions + .filter(({ id }) => importedIds.has(id)) + .map((definition) => [ + definition.id, + { + definitionDigest: definitionDigest(definition), + modelRoute: definition.modelRoute, + }, + ]), + ); + const disabledIds = new Set(library.disabledIds); + return { + personas: catalog.personas.map((persona) => ({ + ...persona, + imported: importedIds.has(persona.personaId), + ...(editable.has(persona.personaId) + ? { editable: editable.get(persona.personaId)! } + : {}), + availability: disabledIds.has(persona.personaId) + ? { status: "unavailable" as const, reason: "disabled" as const } + : persona.availability, + })), + }; + }), + { "rpc.aggregate": "orchestrationV2" }, + ), + [ORCHESTRATION_V2_WS_METHODS.importAgentPersonas]: (input) => + observeRpcEffect( + ORCHESTRATION_V2_WS_METHODS.importAgentPersonas, + personaLibrary + .importFiles(input) + .pipe( + Effect.mapError((cause) => + isAgentPersonaImportConflict(cause) + ? cause + : new AgentPersonaCatalogError({ message: String(cause) }), + ), + ), + { "rpc.aggregate": "orchestrationV2" }, + ), + [ORCHESTRATION_V2_WS_METHODS.setImportedAgentPersonaEnabled]: (input) => + observeRpcEffect( + ORCHESTRATION_V2_WS_METHODS.setImportedAgentPersonaEnabled, + personaLibrary + .setImportedEnabled(input.personaId, input.enabled) + .pipe( + Effect.mapError( + (cause) => new AgentPersonaCatalogError({ message: String(cause) }), + ), + ), + { "rpc.aggregate": "orchestrationV2" }, + ), + [ORCHESTRATION_V2_WS_METHODS.removeImportedAgentPersona]: (input) => + observeRpcEffect( + ORCHESTRATION_V2_WS_METHODS.removeImportedAgentPersona, + personaLibrary + .removeImported(input.personaId) + .pipe( + Effect.mapError( + (cause) => new AgentPersonaCatalogError({ message: String(cause) }), + ), + ), + { "rpc.aggregate": "orchestrationV2" }, + ), + [ORCHESTRATION_V2_WS_METHODS.removeSourceAgentPersona]: (input) => + observeRpcEffect( + ORCHESTRATION_V2_WS_METHODS.removeSourceAgentPersona, + personaLibrary + .removeSource(input.personaId) + .pipe( + Effect.mapError( + (cause) => new AgentPersonaCatalogError({ message: String(cause) }), + ), + ), + { "rpc.aggregate": "orchestrationV2" }, + ), + [ORCHESTRATION_V2_WS_METHODS.removeAgentPersona]: (input) => + observeRpcEffect( + ORCHESTRATION_V2_WS_METHODS.removeAgentPersona, + personaLibrary + .removeAgent(input.personaId) + .pipe( + Effect.mapError( + (cause) => new AgentPersonaCatalogError({ message: String(cause) }), + ), + ), + { "rpc.aggregate": "orchestrationV2" }, + ), + [ORCHESTRATION_V2_WS_METHODS.editImportedAgentPersona]: (input) => + observeRpcEffect( + ORCHESTRATION_V2_WS_METHODS.editImportedAgentPersona, + personaLibrary + .editImported(input) + .pipe( + Effect.mapError( + (cause) => new AgentPersonaCatalogError({ message: String(cause) }), + ), + ), { "rpc.aggregate": "orchestrationV2" }, ), [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => diff --git a/packages/client-runtime/src/state/agentPersonas.test.ts b/packages/client-runtime/src/state/agentPersonas.test.ts index 9b99d6d9e9e7d..af1156802663f 100644 --- a/packages/client-runtime/src/state/agentPersonas.test.ts +++ b/packages/client-runtime/src/state/agentPersonas.test.ts @@ -1,12 +1,21 @@ import { BUILT_IN_AGENT_PERSONA_IDS, + AgentPersonaImportConflictError, + type AgentPersonaImportInput, ProviderDriverKind, ProviderInstanceId, type OrchestrationV2AgentPersonaCatalog, + type ServerProvider, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { presentAgentPersonaAssignment, presentAgentPersonaCatalog } from "./agentPersonas.ts"; +import { + prepareAgentPersonaImport, + importAgentPersonasWithConfirmation, + agentPersonaModelChoices, + presentAgentPersonaAssignment, + presentAgentPersonaCatalog, +} from "./agentPersonas.ts"; const catalog: OrchestrationV2AgentPersonaCatalog = { personas: BUILT_IN_AGENT_PERSONA_IDS.map((personaId, index) => ({ @@ -94,3 +103,310 @@ describe("agent persona catalog presentation", () => { ); }); }); + +it("presents custom library ids and immutable display names on every client", () => { + const custom = { + ...catalog.personas[0]!, + personaId: "team-researcher", + displayName: "Team Researcher", + outputArtifact: "TeamBrief", + }; + expect(presentAgentPersonaCatalog({ personas: [custom] })[0]).toMatchObject({ + personaId: "team-researcher", + displayName: "Team Researcher", + outputArtifact: "TeamBrief", + }); + expect( + presentAgentPersonaAssignment({ + personaId: custom.personaId, + displayName: "Original Researcher", + definitionVersion: 2, + definitionDigest: "a".repeat(64), + authorityPolicy: "read-only", + resolvedRoute: "primary", + resolvedDriver: ProviderDriverKind.make("codex"), + resolvedModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "research-model", + }, + }).personaLabel, + ).toBe("Original Researcher"); + expect(presentAgentPersonaCatalog({ personas: [] })).toEqual([]); +}); + +describe("persona import selection", () => { + const file = (name: string, content = "{}", size = content.length) => ({ + name, + size, + text: async () => content, + }); + it("includes nested JSON and YAML definitions and ignores accompanying documentation", async () => { + expect( + await prepareAgentPersonaImport([ + file("team/one/agent.json", "one"), + file("team/two/agent.JSON", "two"), + file("team/three/agent.yaml", "three"), + file("team/four/agent.YML", "four"), + { + name: "team/README.md", + size: 1, + text: async () => { + throw new Error("Must not read"); + }, + }, + ]), + ).toEqual([ + { name: "team/one/agent.json", content: "one" }, + { name: "team/two/agent.JSON", content: "two" }, + { name: "team/three/agent.yaml", content: "three" }, + { name: "team/four/agent.YML", content: "four" }, + ]); + }); + it("imports only the selected individual definition", async () => { + expect(await prepareAgentPersonaImport([file("agent.json", "selected")])).toEqual([ + { name: "agent.json", content: "selected" }, + ]); + }); + it("reports empty folders and file limits before reading oversized selections", async () => { + await expect(prepareAgentPersonaImport([file("README.md")])).rejects.toThrow("No JSON"); + await expect(prepareAgentPersonaImport([file("big.json", "", 65537)])).rejects.toThrow( + "64 KiB", + ); + await expect( + prepareAgentPersonaImport(Array.from({ length: 51 }, () => file("agent.json"))), + ).rejects.toThrow("at most 50"); + }); + it("identifies removable imports while treating older catalog entries as source definitions", () => { + const persona = catalog.personas[0]!; + expect(presentAgentPersonaCatalog({ personas: [persona] })[0]?.imported).toBe(false); + expect( + presentAgentPersonaCatalog({ personas: [{ ...persona, imported: true }] })[0]?.imported, + ).toBe(true); + }); +}); + +it("shows disabled imports as off and distinguishes them from enabled agents with unavailable models", () => { + const persona = catalog.personas[1]!; + const disabled = presentAgentPersonaCatalog({ + personas: [ + { ...persona, imported: true, availability: { status: "unavailable", reason: "disabled" } }, + ], + })[0]; + expect(disabled).toMatchObject({ + imported: true, + enabled: false, + availability: "disabled", + availabilityLabel: "Disabled", + route: "Disabled for new launches", + }); + expect(presentAgentPersonaCatalog({ personas: [persona] })[0]).toMatchObject({ + enabled: true, + availability: "blocked", + }); +}); + +it("offers editing only for imported entries with server-provided editable details", () => { + const persona = catalog.personas[0]!; + expect(presentAgentPersonaCatalog({ personas: [persona] })[0]?.edit).toBeNull(); + const modelRoute = [ + { driver: "codex", model: "custom-model", reasoningEffort: "high" }, + { driver: "claudeAgent", model: "fallback-model", reasoningEffort: "medium" }, + ] as const; + expect( + presentAgentPersonaCatalog({ + personas: [ + { ...persona, imported: true, editable: { definitionDigest: "a".repeat(64), modelRoute } }, + ], + })[0]?.edit, + ).toEqual({ + personaId: persona.personaId, + expectedDigest: "a".repeat(64), + displayName: persona.displayName, + description: persona.description, + authorityPolicy: persona.defaultAuthorityPolicy, + modelRoute, + }); + const choices = agentPersonaModelChoices([], modelRoute); + expect(choices).toHaveLength(2); + expect(choices.find(({ target }) => target.driver === "codex")?.efforts).toEqual(["high"]); + expect(choices.every(({ label }) => label.includes("not advertised"))).toBe(true); +}); + +it("uses the selected environment's advertised models and reasoning options without duplicating instances", () => { + const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("remote-codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-08T00:00:00.000Z", + availability: "available", + slashCommands: [], + skills: [], + models: [ + { + slug: "team-model", + name: "Team model", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + ], + }, + }, + ], + }; + const choices = agentPersonaModelChoices( + [provider, { ...provider, instanceId: ProviderInstanceId.make("second-codex") }], + [], + ); + expect(choices).toHaveLength(1); + expect(choices[0]).toMatchObject({ + label: "Codex · team-model", + target: { driver: "codex", model: "team-model", reasoningEffort: "high" }, + efforts: ["medium", "high"], + }); +}); + +describe("import replacement confirmation", () => { + const files = [{ name: "agent.json", content: "selected content" }]; + const conflict = new AgentPersonaImportConflictError({ + message: "Already exists", + conflicts: [{ personaId: "scout", displayName: "Scout", definitionDigest: "a".repeat(64) }], + }); + + it("imports a new selection without showing confirmation", async () => { + let prompts = 0; + const result = await importAgentPersonasWithConfirmation( + files, + async (input) => { + expect(input).toEqual({ files, replaceExisting: false }); + return { importedIds: ["scout"] }; + }, + async () => { + prompts++; + return null; + }, + ); + expect(result?.importedIds).toEqual(["scout"]); + expect(prompts).toBe(0); + }); + + it("cancels without sending a replacement request", async () => { + const requests: AgentPersonaImportInput[] = []; + const result = await importAgentPersonasWithConfirmation( + files, + async (input) => { + requests.push(input); + throw conflict; + }, + async () => null, + ); + expect(result).toBeNull(); + expect(requests).toEqual([{ files, replaceExisting: false }]); + }); + + it("retries the same files with the exact conflicts the user approved", async () => { + const requests: AgentPersonaImportInput[] = []; + const result = await importAgentPersonasWithConfirmation( + files, + async (input) => { + requests.push(input); + if (!input.replaceExisting) throw conflict; + return { importedIds: ["scout"] }; + }, + async (error) => { + expect(error.conflicts).toEqual(conflict.conflicts); + return error.conflicts; + }, + ); + expect(requests[1]).toEqual({ + files, + replaceExisting: true, + confirmedConflicts: conflict.conflicts, + skippedPersonaIds: [], + }); + expect(result?.importedIds).toEqual(["scout"]); + }); + + it("asks again on changed conflicts and propagates unrelated failures without prompting", async () => { + const changed = new AgentPersonaImportConflictError({ + message: "Changed", + conflicts: [{ ...conflict.conflicts[0]!, definitionDigest: "b".repeat(64) }], + }); + let requests = 0; + let prompts = 0; + const result = await importAgentPersonasWithConfirmation( + files, + async () => { + throw requests++ === 0 ? conflict : changed; + }, + async (error) => (++prompts === 1 ? error.conflicts : null), + ); + expect(result).toBeNull(); + expect(requests).toBe(2); + expect(prompts).toBe(2); + await expect( + importAgentPersonasWithConfirmation( + files, + async () => { + throw new Error("Invalid JSON"); + }, + async () => { + throw new Error("Should not prompt"); + }, + ), + ).rejects.toThrow("Invalid JSON"); + }); +}); + +it("retains skipped agents through fresh confirmation and accepts an empty replacement selection", async () => { + const files = [{ name: "folder/agent.json", content: "selected content" }]; + const first = new AgentPersonaImportConflictError({ + message: "Already exists", + conflicts: [ + { personaId: "keep", displayName: "Keep", definitionDigest: "a".repeat(64) }, + { personaId: "replace", displayName: "Replace", definitionDigest: "b".repeat(64) }, + ], + }); + const changed = new AgentPersonaImportConflictError({ + message: "Changed", + conflicts: [{ ...first.conflicts[1]!, definitionDigest: "c".repeat(64) }], + }); + const requests: AgentPersonaImportInput[] = []; + let prompts = 0; + const result = await importAgentPersonasWithConfirmation( + files, + async (input) => { + requests.push(input); + if (requests.length === 1) throw first; + if (requests.length === 2) throw changed; + return { importedIds: ["new-agent"] }; + }, + async (error) => + ++prompts === 1 ? error.conflicts.filter(({ personaId }) => personaId === "replace") : [], + ); + expect(requests[1]).toEqual({ + files, + replaceExisting: true, + confirmedConflicts: [first.conflicts[1]], + skippedPersonaIds: ["keep"], + }); + expect(requests[2]).toEqual({ + files, + replaceExisting: true, + confirmedConflicts: [], + skippedPersonaIds: ["keep", "replace"], + }); + expect(result?.importedIds).toEqual(["new-agent"]); +}); diff --git a/packages/client-runtime/src/state/agentPersonas.ts b/packages/client-runtime/src/state/agentPersonas.ts index 6a9626f1e0dc7..49f9997de4635 100644 --- a/packages/client-runtime/src/state/agentPersonas.ts +++ b/packages/client-runtime/src/state/agentPersonas.ts @@ -1,6 +1,18 @@ +import * as Schema from "effect/Schema"; +import { + AgentPersonaImportConflictError, + isAgentPersonaDefinitionFile, + AGENT_PERSONA_IMPORT_MAX_BYTES, + AGENT_PERSONA_IMPORT_MAX_FILES, +} from "@t3tools/contracts"; import type { AgentPersonaAuthorityPolicy, - BuiltInAgentPersonaId, + AgentPersonaImportInput, + AgentPersonaImportConflict, + AgentPersonaEditInput, + AgentPersonaModelTarget, + ServerProvider, + AgentPersonaId, OrchestrationV2AgentPersonaAssignment, OrchestrationV2AgentPersonaCatalog, } from "@t3tools/contracts"; @@ -15,14 +27,17 @@ const AUTHORITY_LABELS: Readonly> = }; export interface AgentPersonaCatalogRow { - readonly personaId: BuiltInAgentPersonaId; + readonly personaId: AgentPersonaId; + readonly imported: boolean; + readonly enabled: boolean; + readonly edit: AgentPersonaEditInput | null; readonly displayName: string; readonly description: string; readonly acceptedInput: string; readonly outputArtifact: string; readonly authority: string; - readonly availability: "available" | "blocked"; - readonly availabilityLabel: "Available" | "Blocked"; + readonly availability: "available" | "blocked" | "disabled"; + readonly availabilityLabel: "Available" | "Blocked" | "Disabled"; readonly route: string; } @@ -38,7 +53,9 @@ function providerLabel(driver: OrchestrationV2AgentPersonaAssignment["resolvedDr export function presentAgentPersonaAssignment( assignment: OrchestrationV2AgentPersonaAssignment, ): AgentPersonaAssignmentPresentation { - const name = `${assignment.personaId[0]?.toUpperCase()}${assignment.personaId.slice(1)}`; + const name = + assignment.displayName ?? + `${assignment.personaId[0]?.toUpperCase()}${assignment.personaId.slice(1)}`; const mode = assignment.authorityPolicy === "critic-fix" ? " · Fix" @@ -63,8 +80,23 @@ export function presentAgentPersonaCatalog( ): ReadonlyArray { return catalog.personas.map((persona) => { const available = persona.availability.status === "available"; + const disabled = + persona.availability.status === "unavailable" && persona.availability.reason === "disabled"; return { personaId: persona.personaId, + imported: persona.imported ?? false, + enabled: !disabled, + edit: + persona.imported && persona.editable + ? { + personaId: persona.personaId, + expectedDigest: persona.editable.definitionDigest, + displayName: persona.displayName, + description: persona.description, + authorityPolicy: persona.defaultAuthorityPolicy, + modelRoute: persona.editable.modelRoute, + } + : null, displayName: persona.displayName, description: persona.description, acceptedInput: persona.acceptedInput, @@ -75,13 +107,142 @@ export function presentAgentPersonaCatalog( `${AUTHORITY_LABELS[policy]}${policy === persona.defaultAuthorityPolicy ? " (default)" : ""}`, ) .join(", "), - availability: available ? "available" : "blocked", - availabilityLabel: available ? "Available" : "Blocked", + availability: disabled ? "disabled" : available ? "available" : "blocked", + availabilityLabel: disabled ? "Disabled" : available ? "Available" : "Blocked", route: available ? `${providerLabel(persona.availability.resolvedDriver)} · ${persona.availability.resolvedModelSelection.model} · ${persona.availability.resolvedRoute}` : persona.availability.reason === "authority-not-enforceable" ? "Required authority is not yet enforceable" - : "Primary and fallback models unavailable", + : disabled + ? "Disabled for new launches" + : "Primary and fallback models unavailable", }; }); } + +/** Read only selected JSON and YAML files; paths are labels, never server filesystem destinations. */ +export async function prepareAgentPersonaImport( + files: ReadonlyArray<{ + readonly name: string; + readonly size: number; + readonly text: () => Promise; + }>, +) { + const definitions = files.filter((file) => isAgentPersonaDefinitionFile(file.name)); + if (definitions.length === 0) + throw new Error("No JSON or YAML agent definitions found in the selection."); + if (definitions.length > AGENT_PERSONA_IMPORT_MAX_FILES) + throw new Error( + `Select at most ${AGENT_PERSONA_IMPORT_MAX_FILES} agent definitions at a time.`, + ); + const result: Array<{ name: string; content: string }> = []; + for (const file of definitions) { + if (file.size > AGENT_PERSONA_IMPORT_MAX_BYTES) throw new Error(`${file.name} exceeds 64 KiB.`); + result.push({ name: file.name, content: await file.text() }); + } + return result; +} + +export const AGENT_PERSONA_POLICY_OPTIONS = [ + { value: "read-only", label: "Read only" }, + { value: "workspace-write", label: "Workspace write" }, + { value: "critic-review", label: "Review only" }, + { value: "critic-fix", label: "Targeted fixes" }, + { value: "diagnostic", label: "Diagnostic writes (not yet supported)" }, + { value: "publish-only", label: "Publish only (not yet supported)" }, +] as const satisfies ReadonlyArray<{ value: AgentPersonaAuthorityPolicy; label: string }>; + +export const agentPersonaModelChoiceId = (target: AgentPersonaModelTarget) => + JSON.stringify([target.driver, target.model]); + +/** Keep configured models selectable even when they are absent from the environment's current catalog. */ +export function agentPersonaModelChoices( + providers: ReadonlyArray, + current: ReadonlyArray, +) { + const choices = new Map< + string, + { + id: string; + label: string; + target: AgentPersonaModelTarget; + efforts: string[]; + } + >(); + for (const provider of providers) { + if (provider.driver !== "codex" && provider.driver !== "claudeAgent") continue; + const driver = provider.driver === "codex" ? "codex" : "claudeAgent"; + for (const model of provider.models) { + const descriptor = model.capabilities?.optionDescriptors?.find( + ({ id }) => id === (driver === "codex" ? "reasoningEffort" : "effort"), + ); + if (descriptor?.type !== "select" || descriptor.options.length === 0) continue; + const efforts = descriptor.options.map(({ id }) => id); + const target = { + driver, + model: model.slug, + reasoningEffort: efforts.includes("high") ? "high" : efforts[0]!, + } satisfies AgentPersonaModelTarget; + const id = agentPersonaModelChoiceId(target); + const previous = choices.get(id); + choices.set(id, { + id, + label: `${driver === "codex" ? "Codex" : "Claude"} · ${model.slug}`, + target, + efforts: [...new Set([...(previous?.efforts ?? []), ...efforts])], + }); + } + } + for (const target of current) { + const id = agentPersonaModelChoiceId(target); + const choice = choices.get(id); + if (!choice) + choices.set(id, { + id, + label: `${target.driver === "codex" ? "Codex" : "Claude"} · ${target.model} (not advertised)`, + target, + efforts: [target.reasoningEffort], + }); + else if (!choice.efforts.includes(target.reasoningEffort)) + choice.efforts.push(target.reasoningEffort); + } + return [...choices.values()].sort((a, b) => a.label.localeCompare(b.label)); +} + +const isImportConflict = Schema.is(AgentPersonaImportConflictError); + +/** Import new agents and approved replacements, retaining skipped IDs across confirmations. */ +export async function importAgentPersonasWithConfirmation( + files: AgentPersonaImportInput["files"], + request: ( + input: AgentPersonaImportInput, + ) => Promise<{ readonly importedIds: ReadonlyArray }>, + confirm: ( + error: AgentPersonaImportConflictError, + ) => Promise | null>, +) { + let input: AgentPersonaImportInput = { files, replaceExisting: false }; + const skippedPersonaIds = new Set(); + while (true) { + try { + return await request(input); + } catch (error) { + if (!isImportConflict(error)) throw error; + const selected = await confirm(error); + if (selected === null) return null; + for (const conflict of error.conflicts) { + if (!selected.some(({ personaId }) => personaId === conflict.personaId)) + skippedPersonaIds.add(conflict.personaId); + } + input = { + files, + replaceExisting: true, + confirmedConflicts: selected, + skippedPersonaIds: [...skippedPersonaIds], + }; + } + } +} + +export const AGENT_PERSONA_IMPORT_CONFIRMATION_MESSAGE = + "Turn off agents you don’t want to replace."; diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index 8bc7afe1647bc..4fa1bb24a0c35 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -17,10 +17,34 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration-v2:dispatch-command", tag: ORCHESTRATION_V2_WS_METHODS.dispatchCommand, }), + importAgentPersonas: createEnvironmentRpcCommand(runtime, { + label: "environment-data:orchestration-v2:import-agent-personas", + tag: ORCHESTRATION_V2_WS_METHODS.importAgentPersonas, + }), + setImportedAgentPersonaEnabled: createEnvironmentRpcCommand(runtime, { + label: "environment-data:orchestration-v2:set-imported-agent-persona-enabled", + tag: ORCHESTRATION_V2_WS_METHODS.setImportedAgentPersonaEnabled, + }), + removeImportedAgentPersona: createEnvironmentRpcCommand(runtime, { + label: "environment-data:orchestration-v2:remove-imported-agent-persona", + tag: ORCHESTRATION_V2_WS_METHODS.removeImportedAgentPersona, + }), + removeSourceAgentPersona: createEnvironmentRpcCommand(runtime, { + label: "environment-data:orchestration-v2:remove-source-agent-persona", + tag: ORCHESTRATION_V2_WS_METHODS.removeSourceAgentPersona, + }), + removeAgentPersona: createEnvironmentRpcCommand(runtime, { + label: "environment-data:orchestration-v2:remove-agent-persona", + tag: ORCHESTRATION_V2_WS_METHODS.removeAgentPersona, + }), + editImportedAgentPersona: createEnvironmentRpcCommand(runtime, { + label: "environment-data:orchestration-v2:edit-imported-agent-persona", + tag: ORCHESTRATION_V2_WS_METHODS.editImportedAgentPersona, + }), agentPersonaCatalog: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration-v2:agent-persona-catalog", tag: ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog, - staleTimeMs: 30_000, + staleTimeMs: 0, }), threadProjection: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration-v2:thread-projection", diff --git a/packages/contracts/src/j5/agentPersona.ts b/packages/contracts/src/j5/agentPersona.ts index a11ce407c5d4d..82c760a5f885c 100644 --- a/packages/contracts/src/j5/agentPersona.ts +++ b/packages/contracts/src/j5/agentPersona.ts @@ -23,6 +23,11 @@ export const BUILT_IN_AGENT_PERSONA_IDS = [ "herald", ] as const; +export const AgentPersonaId = TrimmedNonEmptyString.check( + Schema.isPattern(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/), +); +export type AgentPersonaId = typeof AgentPersonaId.Type; + export const BuiltInAgentPersonaId = Schema.Literals(BUILT_IN_AGENT_PERSONA_IDS); export type BuiltInAgentPersonaId = typeof BuiltInAgentPersonaId.Type; @@ -52,15 +57,17 @@ export const BuiltInAgentArtifactId = Schema.Literals(BUILT_IN_AGENT_ARTIFACT_ID export type BuiltInAgentArtifactId = typeof BuiltInAgentArtifactId.Type; export const OrchestrationV2AgentPersonaRequest = Schema.Struct({ - personaId: BuiltInAgentPersonaId, + personaId: AgentPersonaId, authorityPolicy: Schema.optional(AgentPersonaAuthorityPolicy), }); export type OrchestrationV2AgentPersonaRequest = typeof OrchestrationV2AgentPersonaRequest.Type; -/** Immutable launch-time provenance for a thread assigned to a built-in persona. */ +/** Immutable launch-time provenance; definitionDigest references an environment-owned snapshot. */ export const OrchestrationV2AgentPersonaAssignment = Schema.Struct({ - personaId: BuiltInAgentPersonaId, + personaId: AgentPersonaId, definitionVersion: PositiveInt, + definitionDigest: Schema.optional(Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/))), + displayName: Schema.optional(TrimmedNonEmptyString), authorityPolicy: AgentPersonaAuthorityPolicy, resolvedRoute: Schema.Literals(["primary", "fallback"]), resolvedDriver: ProviderDriverKind, @@ -78,20 +85,42 @@ export const OrchestrationV2AgentPersonaAvailability = Schema.Union([ }), Schema.Struct({ status: Schema.Literal("unavailable"), - reason: Schema.Literals(["routes-unavailable", "authority-not-enforceable"]), + reason: Schema.Literals(["routes-unavailable", "authority-not-enforceable", "disabled"]), }), ]); export type OrchestrationV2AgentPersonaAvailability = typeof OrchestrationV2AgentPersonaAvailability.Type; -/** Environment-specific, presentation-safe view of one built-in persona. */ +export const AgentPersonaModelTarget = Schema.Struct({ + driver: Schema.Literals(["codex", "claudeAgent"]), + model: TrimmedNonEmptyString, + reasoningEffort: TrimmedNonEmptyString, +}); +export type AgentPersonaModelTarget = typeof AgentPersonaModelTarget.Type; +export const AgentPersonaEditableDetails = Schema.Struct({ + definitionDigest: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + modelRoute: Schema.Tuple([AgentPersonaModelTarget, AgentPersonaModelTarget]), +}); +export const AgentPersonaEditInput = Schema.Struct({ + personaId: AgentPersonaId, + expectedDigest: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), + displayName: TrimmedNonEmptyString.check(Schema.isMaxLength(65536)), + description: TrimmedNonEmptyString.check(Schema.isMaxLength(65536)), + authorityPolicy: AgentPersonaAuthorityPolicy, + modelRoute: Schema.Tuple([AgentPersonaModelTarget, AgentPersonaModelTarget]), +}); +export type AgentPersonaEditInput = typeof AgentPersonaEditInput.Type; + +/** Environment-specific, presentation-safe view of one library persona. */ export const OrchestrationV2AgentPersonaCatalogEntry = Schema.Struct({ - personaId: BuiltInAgentPersonaId, + personaId: AgentPersonaId, + imported: Schema.optional(Schema.Boolean), + editable: Schema.optional(AgentPersonaEditableDetails), definitionVersion: PositiveInt, displayName: TrimmedNonEmptyString, description: TrimmedNonEmptyString, acceptedInput: TrimmedNonEmptyString, - outputArtifact: BuiltInAgentArtifactId, + outputArtifact: TrimmedNonEmptyString, defaultAuthorityPolicy: AgentPersonaAuthorityPolicy, allowedAuthorityPolicies: Schema.Array(AgentPersonaAuthorityPolicy), availability: OrchestrationV2AgentPersonaAvailability, @@ -99,7 +128,47 @@ export const OrchestrationV2AgentPersonaCatalogEntry = Schema.Struct({ export type OrchestrationV2AgentPersonaCatalogEntry = typeof OrchestrationV2AgentPersonaCatalogEntry.Type; +export class AgentPersonaCatalogError extends Schema.TaggedErrorClass()( + "AgentPersonaCatalogError", + { message: Schema.String }, +) {} + export const OrchestrationV2AgentPersonaCatalog = Schema.Struct({ personas: Schema.Array(OrchestrationV2AgentPersonaCatalogEntry), }); export type OrchestrationV2AgentPersonaCatalog = typeof OrchestrationV2AgentPersonaCatalog.Type; + +export const isAgentPersonaDefinitionFile = (name: string): boolean => + /\.(json|ya?ml)$/i.test(name); + +export const AGENT_PERSONA_IMPORT_MAX_FILES = 50; +export const AGENT_PERSONA_IMPORT_MAX_BYTES = 65536; +export const AgentPersonaImportConflict = Schema.Struct({ + personaId: AgentPersonaId, + displayName: TrimmedNonEmptyString, + definitionDigest: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), +}); +export type AgentPersonaImportConflict = typeof AgentPersonaImportConflict.Type; +export class AgentPersonaImportConflictError extends Schema.TaggedErrorClass()( + "AgentPersonaImportConflictError", + { message: Schema.String, conflicts: Schema.Array(AgentPersonaImportConflict) }, +) {} + +export const AgentPersonaImportInput = Schema.Struct({ + files: Schema.Array( + Schema.Struct({ + name: TrimmedNonEmptyString.check(Schema.isMaxLength(1024)), + content: Schema.String.check(Schema.isMaxLength(AGENT_PERSONA_IMPORT_MAX_BYTES)), + }), + ).check(Schema.isMinLength(1), Schema.isMaxLength(AGENT_PERSONA_IMPORT_MAX_FILES)), + replaceExisting: Schema.Boolean, + skippedPersonaIds: Schema.optional( + Schema.Array(AgentPersonaId).check(Schema.isMaxLength(AGENT_PERSONA_IMPORT_MAX_FILES)), + ), + confirmedConflicts: Schema.optional( + Schema.Array(AgentPersonaImportConflict).check( + Schema.isMaxLength(AGENT_PERSONA_IMPORT_MAX_FILES), + ), + ), +}); +export type AgentPersonaImportInput = typeof AgentPersonaImportInput.Type; diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 0075a7f76fa5e..8234047b8c20b 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -1,3 +1,4 @@ +import { AgentPersonaId, OrchestrationV2AgentPersonaAssignment } from "./j5/agentPersona.ts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Schema from "effect/Schema"; @@ -802,3 +803,30 @@ describe("orchestration V2 contracts", () => { expect(shell.pendingBackgroundTasks).toEqual([]); }); }); + +const decodePersonaId = Schema.decodeUnknownSync(AgentPersonaId); +const decodePersonaAssignment = Schema.decodeUnknownSync(OrchestrationV2AgentPersonaAssignment); + +it("accepts custom persona ids and validates snapshot references without requiring them on legacy assignments", () => { + expect(decodePersonaId("team-researcher")).toBe("team-researcher"); + expect(() => decodePersonaId("../escape")).toThrow(); + const assignment = { + personaId: "team-researcher", + definitionVersion: 4, + displayName: "Team Researcher", + definitionDigest: "a".repeat(64), + authorityPolicy: "read-only", + resolvedRoute: "primary", + resolvedDriver: "codex", + resolvedModelSelection: { instanceId: "codex", model: "research-model" }, + }; + expect(decodePersonaAssignment(assignment)).toEqual(assignment); + expect(() => + decodePersonaAssignment({ + ...assignment, + definitionDigest: "../escape", + }), + ).toThrow(); + const { definitionDigest: _digest, displayName: _name, ...legacy } = assignment; + expect(decodePersonaAssignment(legacy).definitionDigest).toBeUndefined(); +}); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 7ecd24b9865cc..11ead10846f8f 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -36,7 +36,11 @@ import { } from "./checkpointDiff.ts"; import { ModelSelection } from "./modelSelection.ts"; import { + AgentPersonaEditInput, + AgentPersonaId, + AgentPersonaImportInput, OrchestrationV2AgentPersonaAssignment, + OrchestrationV2AgentPersonaCatalog, OrchestrationV2AgentPersonaRequest, } from "./j5/agentPersona.ts"; import { ThreadLinkedPullRequest } from "./orchestration.ts"; @@ -2369,6 +2373,12 @@ export type OrchestrationV2PublicCommand = typeof OrchestrationV2PublicCommand.T export const ORCHESTRATION_V2_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", getAgentPersonaCatalog: "orchestration.getAgentPersonaCatalog", + importAgentPersonas: "orchestration.importAgentPersonas", + editImportedAgentPersona: "orchestration.editImportedAgentPersona", + removeImportedAgentPersona: "orchestration.removeImportedAgentPersona", + removeSourceAgentPersona: "orchestration.removeSourceAgentPersona", + removeAgentPersona: "orchestration.removeAgentPersona", + setImportedAgentPersonaEnabled: "orchestration.setImportedAgentPersonaEnabled", getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", searchThreads: "orchestration.searchThreads", @@ -2667,6 +2677,30 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2PublicCommand, output: OrchestrationV2DispatchCommandResult, }, + editImportedAgentPersona: { + input: AgentPersonaEditInput, + output: Schema.Void, + }, + importAgentPersonas: { + input: AgentPersonaImportInput, + output: Schema.Struct({ importedIds: Schema.Array(AgentPersonaId) }), + }, + setImportedAgentPersonaEnabled: { + input: Schema.Struct({ personaId: AgentPersonaId, enabled: Schema.Boolean }), + output: Schema.Void, + }, + removeImportedAgentPersona: { + input: Schema.Struct({ personaId: AgentPersonaId }), + output: Schema.Void, + }, + removeSourceAgentPersona: { + input: Schema.Struct({ personaId: AgentPersonaId }), + output: Schema.Void, + }, + removeAgentPersona: { + input: Schema.Struct({ personaId: AgentPersonaId }), + output: Schema.Void, + }, getAgentPersonaCatalog: { input: Schema.Struct({}), output: OrchestrationV2AgentPersonaCatalog, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6de7bed767741..4bdc252b354c5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,3 +1,4 @@ +import { AgentPersonaCatalogError, AgentPersonaImportConflictError } from "./j5/agentPersona.ts"; import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; @@ -1116,7 +1117,63 @@ export const WsOrchestrationV2GetAgentPersonaCatalogRpc = Rpc.make( { payload: OrchestrationV2RpcSchemas.getAgentPersonaCatalog.input, success: OrchestrationV2RpcSchemas.getAgentPersonaCatalog.output, - error: EnvironmentAuthorizationError, + error: Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]), + }, +); + +export const WsOrchestrationV2ImportAgentPersonasRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.importAgentPersonas, + { + payload: OrchestrationV2RpcSchemas.importAgentPersonas.input, + success: OrchestrationV2RpcSchemas.importAgentPersonas.output, + error: Schema.Union([ + EnvironmentAuthorizationError, + AgentPersonaCatalogError, + AgentPersonaImportConflictError, + ]), + }, +); +export const WsOrchestrationV2SetImportedAgentPersonaEnabledRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.setImportedAgentPersonaEnabled, + { + payload: OrchestrationV2RpcSchemas.setImportedAgentPersonaEnabled.input, + success: OrchestrationV2RpcSchemas.setImportedAgentPersonaEnabled.output, + error: Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]), + }, +); +export const WsOrchestrationV2RemoveImportedAgentPersonaRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.removeImportedAgentPersona, + { + payload: OrchestrationV2RpcSchemas.removeImportedAgentPersona.input, + success: OrchestrationV2RpcSchemas.removeImportedAgentPersona.output, + error: Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]), + }, +); + +export const WsOrchestrationV2RemoveSourceAgentPersonaRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.removeSourceAgentPersona, + { + payload: OrchestrationV2RpcSchemas.removeSourceAgentPersona.input, + success: OrchestrationV2RpcSchemas.removeSourceAgentPersona.output, + error: Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]), + }, +); + +export const WsOrchestrationV2RemoveAgentPersonaRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.removeAgentPersona, + { + payload: OrchestrationV2RpcSchemas.removeAgentPersona.input, + success: OrchestrationV2RpcSchemas.removeAgentPersona.output, + error: Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]), + }, +); + +export const WsOrchestrationV2EditImportedAgentPersonaRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.editImportedAgentPersona, + { + payload: OrchestrationV2RpcSchemas.editImportedAgentPersona.input, + success: OrchestrationV2RpcSchemas.editImportedAgentPersona.output, + error: Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]), }, ); @@ -1427,6 +1484,12 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeResourceTelemetryRpc, WsOrchestrationV2DispatchCommandRpc, WsOrchestrationV2GetAgentPersonaCatalogRpc, + WsOrchestrationV2ImportAgentPersonasRpc, + WsOrchestrationV2RemoveImportedAgentPersonaRpc, + WsOrchestrationV2RemoveSourceAgentPersonaRpc, + WsOrchestrationV2RemoveAgentPersonaRpc, + WsOrchestrationV2EditImportedAgentPersonaRpc, + WsOrchestrationV2SetImportedAgentPersonaEnabledRpc, WsOrchestrationV2GetWorkflowScriptRpc, WsOrchestrationV2GetTurnDiffRpc, WsOrchestrationV2GetFullThreadDiffRpc, From 70dd1b444d21a29b62866b42edb3825ca75f815b Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Wed, 9 Sep 2026 08:39:41 -0400 Subject: [PATCH 3/7] refactor(agents): serve the persona library RPCs from a J5-owned group The seven library RPCs now live in packages/contracts/src/j5/agentPersona.ts as their own RpcGroup, merged into WsRpcGroup with one appended call so the existing environment-scoped WebSocket transport, remote connections, and mobile keep working unchanged. Server handlers and scope declarations move to apps/server/src/j5/agents/agentPersonaRpc.ts and are spread into the upstream handler and scope tables; client atoms and presentation helpers move under packages/client-runtime/src/j5. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/auth/RpcAuthorization.test.ts | 16 +-- apps/server/src/auth/RpcAuthorization.ts | 10 +- apps/server/src/j5/agents/agentPersonaRpc.ts | 131 ++++++++++++++++++ apps/server/src/ws.ts | 129 +---------------- packages/client-runtime/package.json | 12 +- .../src/j5/agentPersonaAtoms.ts | 45 ++++++ .../src/{state => j5}/agentPersonas.test.ts | 0 .../src/{state => j5}/agentPersonas.ts | 0 .../client-runtime/src/state/orchestration.ts | 29 ---- packages/contracts/src/j5/agentPersona.ts | 121 ++++++++++++++++ packages/contracts/src/orchestrationV2.ts | 39 ------ packages/contracts/src/rpc.ts | 76 +--------- 12 files changed, 324 insertions(+), 284 deletions(-) create mode 100644 apps/server/src/j5/agents/agentPersonaRpc.ts create mode 100644 packages/client-runtime/src/j5/agentPersonaAtoms.ts rename packages/client-runtime/src/{state => j5}/agentPersonas.test.ts (100%) rename packages/client-runtime/src/{state => j5}/agentPersonas.ts (100%) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 453d7718e2273..e4238723f9c42 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -4,7 +4,7 @@ import { AuthRelayReadScope, AuthRelayWriteScope, WS_METHODS, - ORCHESTRATION_V2_WS_METHODS, + J5_AGENT_PERSONA_WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; @@ -56,25 +56,25 @@ describe("RPC authorization scopes", () => { }); it("requires operate access to import, edit, toggle, or remove persona definitions", () => { - expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.editImportedAgentPersona)).toBe( + expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.editImportedAgentPersona)).toBe( AuthOrchestrationOperateScope, ); - expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.removeAgentPersona)).toBe( + expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.removeAgentPersona)).toBe( AuthOrchestrationOperateScope, ); - expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.removeSourceAgentPersona)).toBe( + expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.removeSourceAgentPersona)).toBe( AuthOrchestrationOperateScope, ); expect( - requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.setImportedAgentPersonaEnabled), + requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.setImportedAgentPersonaEnabled), ).toBe(AuthOrchestrationOperateScope); - expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog)).toBe( + expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.getAgentPersonaCatalog)).toBe( AuthOrchestrationReadScope, ); - expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.importAgentPersonas)).toBe( + expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.importAgentPersonas)).toBe( AuthOrchestrationOperateScope, ); - expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.removeImportedAgentPersona)).toBe( + expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.removeImportedAgentPersona)).toBe( AuthOrchestrationOperateScope, ); }); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2d75da95285b6..d060a78f8c4b5 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -13,6 +13,8 @@ import { } from "@t3tools/contracts"; import type * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { AGENT_PERSONA_RPC_SCOPES } from "../j5/agents/agentPersonaRpc.ts"; + type WsRpcMethod = RpcGroup.Rpcs["_tag"]; /** @@ -21,14 +23,8 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; * runtime failure. */ export const RPC_REQUIRED_SCOPES = { + ...AGENT_PERSONA_RPC_SCOPES, [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, - [ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog]: AuthOrchestrationReadScope, - [ORCHESTRATION_V2_WS_METHODS.importAgentPersonas]: AuthOrchestrationOperateScope, - [ORCHESTRATION_V2_WS_METHODS.removeImportedAgentPersona]: AuthOrchestrationOperateScope, - [ORCHESTRATION_V2_WS_METHODS.removeSourceAgentPersona]: AuthOrchestrationOperateScope, - [ORCHESTRATION_V2_WS_METHODS.removeAgentPersona]: AuthOrchestrationOperateScope, - [ORCHESTRATION_V2_WS_METHODS.editImportedAgentPersona]: AuthOrchestrationOperateScope, - [ORCHESTRATION_V2_WS_METHODS.setImportedAgentPersonaEnabled]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, diff --git a/apps/server/src/j5/agents/agentPersonaRpc.ts b/apps/server/src/j5/agents/agentPersonaRpc.ts new file mode 100644 index 0000000000000..945185da31daa --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaRpc.ts @@ -0,0 +1,131 @@ +import { + AgentPersonaCatalogError, + AgentPersonaImportConflictError, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + J5_AGENT_PERSONA_WS_METHODS, + type EnvironmentAuthorizationError, + type J5AgentPersonaRpcSchemas, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { definitionDigest, makeAgentPersonaLibrary } from "./agentPersonaLibrary.ts"; +import { buildAgentPersonaCatalog } from "./agentPersonaRouting.ts"; + +const METHODS = J5_AGENT_PERSONA_WS_METHODS; + +/** Spread into `RPC_REQUIRED_SCOPES`; the upstream `satisfies` check still covers every tag. */ +export const AGENT_PERSONA_RPC_SCOPES = { + [METHODS.getAgentPersonaCatalog]: AuthOrchestrationReadScope, + [METHODS.importAgentPersonas]: AuthOrchestrationOperateScope, + [METHODS.editImportedAgentPersona]: AuthOrchestrationOperateScope, + [METHODS.setImportedAgentPersonaEnabled]: AuthOrchestrationOperateScope, + [METHODS.removeImportedAgentPersona]: AuthOrchestrationOperateScope, + [METHODS.removeSourceAgentPersona]: AuthOrchestrationOperateScope, + [METHODS.removeAgentPersona]: AuthOrchestrationOperateScope, +} as const; + +/** Matches the per-session `observeRpcEffect` closure in ws.ts (instrumentation plus scope check). */ +export type ObserveRpcEffect = ( + method: string, + effect: Effect.Effect, + traceAttributes?: Readonly>, +) => Effect.Effect; + +type Input = + (typeof J5AgentPersonaRpcSchemas)[K]["input"]["Type"]; + +const isImportConflict = Schema.is(AgentPersonaImportConflictError); +const catalogError = (cause: unknown) => new AgentPersonaCatalogError({ message: String(cause) }); +const TRACE = { "rpc.aggregate": "j5AgentPersonas" } as const; + +/** Handlers for `J5AgentPersonaRpcGroup`; spread once into the upstream handler object. */ +export const makeAgentPersonaRpcHandlers = Effect.fn("j5.makeAgentPersonaRpcHandlers")( + function* (deps: { + readonly providers: Effect.Effect>; + readonly observe: ObserveRpcEffect; + }) { + const library = yield* makeAgentPersonaLibrary; + const { observe } = deps; + return { + [METHODS.getAgentPersonaCatalog]: (_input: Input<"getAgentPersonaCatalog">) => + observe( + METHODS.getAgentPersonaCatalog, + Effect.gen(function* () { + const current = yield* library.catalog().pipe(Effect.mapError(catalogError)); + const catalog = buildAgentPersonaCatalog(yield* deps.providers, current.definitions); + const importedIds = new Set(current.importedIds); + const editable = new Map( + current.definitions + .filter(({ id }) => importedIds.has(id)) + .map((definition) => [ + definition.id, + { + definitionDigest: definitionDigest(definition), + modelRoute: definition.modelRoute, + }, + ]), + ); + const disabledIds = new Set(current.disabledIds); + return { + personas: catalog.personas.map((persona) => ({ + ...persona, + imported: importedIds.has(persona.personaId), + ...(editable.has(persona.personaId) + ? { editable: editable.get(persona.personaId)! } + : {}), + availability: disabledIds.has(persona.personaId) + ? { status: "unavailable" as const, reason: "disabled" as const } + : persona.availability, + })), + }; + }), + TRACE, + ), + [METHODS.importAgentPersonas]: (input: Input<"importAgentPersonas">) => + observe( + METHODS.importAgentPersonas, + library + .importFiles(input) + .pipe( + Effect.mapError((cause) => (isImportConflict(cause) ? cause : catalogError(cause))), + ), + TRACE, + ), + [METHODS.editImportedAgentPersona]: (input: Input<"editImportedAgentPersona">) => + observe( + METHODS.editImportedAgentPersona, + library.editImported(input).pipe(Effect.mapError(catalogError)), + TRACE, + ), + [METHODS.setImportedAgentPersonaEnabled]: (input: Input<"setImportedAgentPersonaEnabled">) => + observe( + METHODS.setImportedAgentPersonaEnabled, + library + .setImportedEnabled(input.personaId, input.enabled) + .pipe(Effect.mapError(catalogError)), + TRACE, + ), + [METHODS.removeImportedAgentPersona]: (input: Input<"removeImportedAgentPersona">) => + observe( + METHODS.removeImportedAgentPersona, + library.removeImported(input.personaId).pipe(Effect.mapError(catalogError)), + TRACE, + ), + [METHODS.removeSourceAgentPersona]: (input: Input<"removeSourceAgentPersona">) => + observe( + METHODS.removeSourceAgentPersona, + library.removeSource(input.personaId).pipe(Effect.mapError(catalogError)), + TRACE, + ), + [METHODS.removeAgentPersona]: (input: Input<"removeAgentPersona">) => + observe( + METHODS.removeAgentPersona, + library.removeAgent(input.personaId).pipe(Effect.mapError(catalogError)), + TRACE, + ), + }; + }, +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9d07fcdf201c6..108a0fd75e145 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,3 @@ -import { AgentPersonaCatalogError, AgentPersonaImportConflictError } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Encoding from "effect/Encoding"; @@ -134,8 +133,7 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; -import { definitionDigest, makeAgentPersonaLibrary } from "./j5/agents/agentPersonaLibrary.ts"; -import { buildAgentPersonaCatalog } from "./j5/agents/agentPersonaRouting.ts"; +import { makeAgentPersonaRpcHandlers } from "./j5/agents/agentPersonaRpc.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -202,8 +200,6 @@ const resolveDiscoveryForConfig = ( Effect.map(Option.getOrElse(onTimeout)), ); -const isAgentPersonaImportConflict = Schema.is(AgentPersonaImportConflictError); - export const resolveAvailableEditorsForConfig = ( discovery: Effect.Effect, E, R>, ) => resolveDiscoveryForConfig(discovery, () => []); @@ -631,7 +627,6 @@ const makeWsRpcLayer = ( const providerInstallation = yield* makeProviderInstallation(); const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; - const personaLibrary = yield* makeAgentPersonaLibrary; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; @@ -1318,6 +1313,10 @@ const makeWsRpcLayer = ( } }); + const agentPersonaRpcHandlers = yield* makeAgentPersonaRpcHandlers({ + providers: providerRegistry.getProviders, + observe: observeRpcEffect, + }); const handlers = ServerWsRpcGroup.of({ [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( @@ -1387,123 +1386,7 @@ const makeWsRpcLayer = ( : {}), }, ), - [ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog]: (_input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog, - Effect.gen(function* () { - const library = yield* personaLibrary - .catalog() - .pipe( - Effect.mapError( - (cause) => new AgentPersonaCatalogError({ message: String(cause) }), - ), - ); - const catalog = buildAgentPersonaCatalog( - yield* providerRegistry.getProviders, - library.definitions, - ); - const importedIds = new Set(library.importedIds); - const editable = new Map( - library.definitions - .filter(({ id }) => importedIds.has(id)) - .map((definition) => [ - definition.id, - { - definitionDigest: definitionDigest(definition), - modelRoute: definition.modelRoute, - }, - ]), - ); - const disabledIds = new Set(library.disabledIds); - return { - personas: catalog.personas.map((persona) => ({ - ...persona, - imported: importedIds.has(persona.personaId), - ...(editable.has(persona.personaId) - ? { editable: editable.get(persona.personaId)! } - : {}), - availability: disabledIds.has(persona.personaId) - ? { status: "unavailable" as const, reason: "disabled" as const } - : persona.availability, - })), - }; - }), - { "rpc.aggregate": "orchestrationV2" }, - ), - [ORCHESTRATION_V2_WS_METHODS.importAgentPersonas]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.importAgentPersonas, - personaLibrary - .importFiles(input) - .pipe( - Effect.mapError((cause) => - isAgentPersonaImportConflict(cause) - ? cause - : new AgentPersonaCatalogError({ message: String(cause) }), - ), - ), - { "rpc.aggregate": "orchestrationV2" }, - ), - [ORCHESTRATION_V2_WS_METHODS.setImportedAgentPersonaEnabled]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.setImportedAgentPersonaEnabled, - personaLibrary - .setImportedEnabled(input.personaId, input.enabled) - .pipe( - Effect.mapError( - (cause) => new AgentPersonaCatalogError({ message: String(cause) }), - ), - ), - { "rpc.aggregate": "orchestrationV2" }, - ), - [ORCHESTRATION_V2_WS_METHODS.removeImportedAgentPersona]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.removeImportedAgentPersona, - personaLibrary - .removeImported(input.personaId) - .pipe( - Effect.mapError( - (cause) => new AgentPersonaCatalogError({ message: String(cause) }), - ), - ), - { "rpc.aggregate": "orchestrationV2" }, - ), - [ORCHESTRATION_V2_WS_METHODS.removeSourceAgentPersona]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.removeSourceAgentPersona, - personaLibrary - .removeSource(input.personaId) - .pipe( - Effect.mapError( - (cause) => new AgentPersonaCatalogError({ message: String(cause) }), - ), - ), - { "rpc.aggregate": "orchestrationV2" }, - ), - [ORCHESTRATION_V2_WS_METHODS.removeAgentPersona]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.removeAgentPersona, - personaLibrary - .removeAgent(input.personaId) - .pipe( - Effect.mapError( - (cause) => new AgentPersonaCatalogError({ message: String(cause) }), - ), - ), - { "rpc.aggregate": "orchestrationV2" }, - ), - [ORCHESTRATION_V2_WS_METHODS.editImportedAgentPersona]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.editImportedAgentPersona, - personaLibrary - .editImported(input) - .pipe( - Effect.mapError( - (cause) => new AgentPersonaCatalogError({ message: String(cause) }), - ), - ), - { "rpc.aggregate": "orchestrationV2" }, - ), + ...agentPersonaRpcHandlers, [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => observeRpcEffect( ORCHESTRATION_V2_WS_METHODS.getWorkflowScript, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index cf0e5a37d27da..ef2fe76dbfc18 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -87,10 +87,6 @@ "types": "./src/state/auth.ts", "default": "./src/state/auth.ts" }, - "./state/agent-personas": { - "types": "./src/state/agentPersonas.ts", - "default": "./src/state/agentPersonas.ts" - }, "./state/assets": { "types": "./src/state/assets.ts", "default": "./src/state/assets.ts" @@ -219,6 +215,14 @@ "types": "./src/state/threadWorkflows.ts", "default": "./src/state/threadWorkflows.ts" }, + "./j5/agent-personas": { + "types": "./src/j5/agentPersonas.ts", + "default": "./src/j5/agentPersonas.ts" + }, + "./j5/agent-persona-atoms": { + "types": "./src/j5/agentPersonaAtoms.ts", + "default": "./src/j5/agentPersonaAtoms.ts" + }, "./j5/steer-state": { "types": "./src/j5/steerState.ts", "default": "./src/j5/steerState.ts" diff --git a/packages/client-runtime/src/j5/agentPersonaAtoms.ts b/packages/client-runtime/src/j5/agentPersonaAtoms.ts new file mode 100644 index 0000000000000..8cac63184f8b7 --- /dev/null +++ b/packages/client-runtime/src/j5/agentPersonaAtoms.ts @@ -0,0 +1,45 @@ +import { J5_AGENT_PERSONA_WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "../state/runtime.ts"; + +/** Environment-scoped atoms for the J5 agent persona library RPCs; web and mobile each build one. */ +export function createAgentPersonaEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + catalog: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:j5-agent-personas:catalog", + tag: J5_AGENT_PERSONA_WS_METHODS.getAgentPersonaCatalog, + staleTimeMs: 0, + }), + importAgentPersonas: createEnvironmentRpcCommand(runtime, { + label: "environment-data:j5-agent-personas:import", + tag: J5_AGENT_PERSONA_WS_METHODS.importAgentPersonas, + }), + editImportedAgentPersona: createEnvironmentRpcCommand(runtime, { + label: "environment-data:j5-agent-personas:edit-imported", + tag: J5_AGENT_PERSONA_WS_METHODS.editImportedAgentPersona, + }), + setImportedAgentPersonaEnabled: createEnvironmentRpcCommand(runtime, { + label: "environment-data:j5-agent-personas:set-imported-enabled", + tag: J5_AGENT_PERSONA_WS_METHODS.setImportedAgentPersonaEnabled, + }), + removeImportedAgentPersona: createEnvironmentRpcCommand(runtime, { + label: "environment-data:j5-agent-personas:remove-imported", + tag: J5_AGENT_PERSONA_WS_METHODS.removeImportedAgentPersona, + }), + removeSourceAgentPersona: createEnvironmentRpcCommand(runtime, { + label: "environment-data:j5-agent-personas:remove-source", + tag: J5_AGENT_PERSONA_WS_METHODS.removeSourceAgentPersona, + }), + removeAgentPersona: createEnvironmentRpcCommand(runtime, { + label: "environment-data:j5-agent-personas:remove", + tag: J5_AGENT_PERSONA_WS_METHODS.removeAgentPersona, + }), + }; +} diff --git a/packages/client-runtime/src/state/agentPersonas.test.ts b/packages/client-runtime/src/j5/agentPersonas.test.ts similarity index 100% rename from packages/client-runtime/src/state/agentPersonas.test.ts rename to packages/client-runtime/src/j5/agentPersonas.test.ts diff --git a/packages/client-runtime/src/state/agentPersonas.ts b/packages/client-runtime/src/j5/agentPersonas.ts similarity index 100% rename from packages/client-runtime/src/state/agentPersonas.ts rename to packages/client-runtime/src/j5/agentPersonas.ts diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index 4fa1bb24a0c35..728c085937315 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -17,35 +17,6 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration-v2:dispatch-command", tag: ORCHESTRATION_V2_WS_METHODS.dispatchCommand, }), - importAgentPersonas: createEnvironmentRpcCommand(runtime, { - label: "environment-data:orchestration-v2:import-agent-personas", - tag: ORCHESTRATION_V2_WS_METHODS.importAgentPersonas, - }), - setImportedAgentPersonaEnabled: createEnvironmentRpcCommand(runtime, { - label: "environment-data:orchestration-v2:set-imported-agent-persona-enabled", - tag: ORCHESTRATION_V2_WS_METHODS.setImportedAgentPersonaEnabled, - }), - removeImportedAgentPersona: createEnvironmentRpcCommand(runtime, { - label: "environment-data:orchestration-v2:remove-imported-agent-persona", - tag: ORCHESTRATION_V2_WS_METHODS.removeImportedAgentPersona, - }), - removeSourceAgentPersona: createEnvironmentRpcCommand(runtime, { - label: "environment-data:orchestration-v2:remove-source-agent-persona", - tag: ORCHESTRATION_V2_WS_METHODS.removeSourceAgentPersona, - }), - removeAgentPersona: createEnvironmentRpcCommand(runtime, { - label: "environment-data:orchestration-v2:remove-agent-persona", - tag: ORCHESTRATION_V2_WS_METHODS.removeAgentPersona, - }), - editImportedAgentPersona: createEnvironmentRpcCommand(runtime, { - label: "environment-data:orchestration-v2:edit-imported-agent-persona", - tag: ORCHESTRATION_V2_WS_METHODS.editImportedAgentPersona, - }), - agentPersonaCatalog: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:orchestration-v2:agent-persona-catalog", - tag: ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog, - staleTimeMs: 0, - }), threadProjection: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration-v2:thread-projection", tag: ORCHESTRATION_V2_WS_METHODS.getThreadProjection, diff --git a/packages/contracts/src/j5/agentPersona.ts b/packages/contracts/src/j5/agentPersona.ts index 82c760a5f885c..2da933311ff09 100644 --- a/packages/contracts/src/j5/agentPersona.ts +++ b/packages/contracts/src/j5/agentPersona.ts @@ -1,5 +1,8 @@ import * as Schema from "effect/Schema"; +import * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { EnvironmentAuthorizationError } from "../auth.ts"; import { PositiveInt, TrimmedNonEmptyString } from "../baseSchemas.ts"; import { ModelSelection } from "../modelSelection.ts"; import { ProviderDriverKind } from "../providerInstance.ts"; @@ -172,3 +175,121 @@ export const AgentPersonaImportInput = Schema.Struct({ ), }); export type AgentPersonaImportInput = typeof AgentPersonaImportInput.Type; + +// --------------------------------------------------------------------------- +// Library management RPCs. These ride the upstream WebSocket RPC transport via one +// `WsRpcGroup.merge(...)` call so environment scoping, remote connections, and +// mobile keep working without a second wire path, while every definition stays here. +// --------------------------------------------------------------------------- + +export const J5_AGENT_PERSONA_WS_METHODS = { + getAgentPersonaCatalog: "j5.agentPersonas.getCatalog", + importAgentPersonas: "j5.agentPersonas.import", + editImportedAgentPersona: "j5.agentPersonas.editImported", + setImportedAgentPersonaEnabled: "j5.agentPersonas.setImportedEnabled", + removeImportedAgentPersona: "j5.agentPersonas.removeImported", + removeSourceAgentPersona: "j5.agentPersonas.removeSource", + removeAgentPersona: "j5.agentPersonas.remove", +} as const; + +export const J5AgentPersonaRpcSchemas = { + getAgentPersonaCatalog: { + input: Schema.Struct({}), + output: OrchestrationV2AgentPersonaCatalog, + }, + importAgentPersonas: { + input: AgentPersonaImportInput, + output: Schema.Struct({ importedIds: Schema.Array(AgentPersonaId) }), + }, + editImportedAgentPersona: { + input: AgentPersonaEditInput, + output: Schema.Void, + }, + setImportedAgentPersonaEnabled: { + input: Schema.Struct({ personaId: AgentPersonaId, enabled: Schema.Boolean }), + output: Schema.Void, + }, + removeImportedAgentPersona: { + input: Schema.Struct({ personaId: AgentPersonaId }), + output: Schema.Void, + }, + removeSourceAgentPersona: { + input: Schema.Struct({ personaId: AgentPersonaId }), + output: Schema.Void, + }, + removeAgentPersona: { + input: Schema.Struct({ personaId: AgentPersonaId }), + output: Schema.Void, + }, +} as const; + +const catalogErrors = Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]); + +export const WsJ5GetAgentPersonaCatalogRpc = Rpc.make( + J5_AGENT_PERSONA_WS_METHODS.getAgentPersonaCatalog, + { + payload: J5AgentPersonaRpcSchemas.getAgentPersonaCatalog.input, + success: J5AgentPersonaRpcSchemas.getAgentPersonaCatalog.output, + error: catalogErrors, + }, +); +export const WsJ5ImportAgentPersonasRpc = Rpc.make( + J5_AGENT_PERSONA_WS_METHODS.importAgentPersonas, + { + payload: J5AgentPersonaRpcSchemas.importAgentPersonas.input, + success: J5AgentPersonaRpcSchemas.importAgentPersonas.output, + error: Schema.Union([ + EnvironmentAuthorizationError, + AgentPersonaCatalogError, + AgentPersonaImportConflictError, + ]), + }, +); +export const WsJ5EditImportedAgentPersonaRpc = Rpc.make( + J5_AGENT_PERSONA_WS_METHODS.editImportedAgentPersona, + { + payload: J5AgentPersonaRpcSchemas.editImportedAgentPersona.input, + success: J5AgentPersonaRpcSchemas.editImportedAgentPersona.output, + error: catalogErrors, + }, +); +export const WsJ5SetImportedAgentPersonaEnabledRpc = Rpc.make( + J5_AGENT_PERSONA_WS_METHODS.setImportedAgentPersonaEnabled, + { + payload: J5AgentPersonaRpcSchemas.setImportedAgentPersonaEnabled.input, + success: J5AgentPersonaRpcSchemas.setImportedAgentPersonaEnabled.output, + error: catalogErrors, + }, +); +export const WsJ5RemoveImportedAgentPersonaRpc = Rpc.make( + J5_AGENT_PERSONA_WS_METHODS.removeImportedAgentPersona, + { + payload: J5AgentPersonaRpcSchemas.removeImportedAgentPersona.input, + success: J5AgentPersonaRpcSchemas.removeImportedAgentPersona.output, + error: catalogErrors, + }, +); +export const WsJ5RemoveSourceAgentPersonaRpc = Rpc.make( + J5_AGENT_PERSONA_WS_METHODS.removeSourceAgentPersona, + { + payload: J5AgentPersonaRpcSchemas.removeSourceAgentPersona.input, + success: J5AgentPersonaRpcSchemas.removeSourceAgentPersona.output, + error: catalogErrors, + }, +); +export const WsJ5RemoveAgentPersonaRpc = Rpc.make(J5_AGENT_PERSONA_WS_METHODS.removeAgentPersona, { + payload: J5AgentPersonaRpcSchemas.removeAgentPersona.input, + success: J5AgentPersonaRpcSchemas.removeAgentPersona.output, + error: catalogErrors, +}); + +/** Merged into `WsRpcGroup` by one appended call; no other upstream registration exists. */ +export const J5AgentPersonaRpcGroup = RpcGroup.make( + WsJ5GetAgentPersonaCatalogRpc, + WsJ5ImportAgentPersonasRpc, + WsJ5EditImportedAgentPersonaRpc, + WsJ5SetImportedAgentPersonaEnabledRpc, + WsJ5RemoveImportedAgentPersonaRpc, + WsJ5RemoveSourceAgentPersonaRpc, + WsJ5RemoveAgentPersonaRpc, +); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 11ead10846f8f..6c140393cf0c2 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -36,11 +36,7 @@ import { } from "./checkpointDiff.ts"; import { ModelSelection } from "./modelSelection.ts"; import { - AgentPersonaEditInput, - AgentPersonaId, - AgentPersonaImportInput, OrchestrationV2AgentPersonaAssignment, - OrchestrationV2AgentPersonaCatalog, OrchestrationV2AgentPersonaRequest, } from "./j5/agentPersona.ts"; import { ThreadLinkedPullRequest } from "./orchestration.ts"; @@ -2372,13 +2368,6 @@ export type OrchestrationV2PublicCommand = typeof OrchestrationV2PublicCommand.T export const ORCHESTRATION_V2_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", - getAgentPersonaCatalog: "orchestration.getAgentPersonaCatalog", - importAgentPersonas: "orchestration.importAgentPersonas", - editImportedAgentPersona: "orchestration.editImportedAgentPersona", - removeImportedAgentPersona: "orchestration.removeImportedAgentPersona", - removeSourceAgentPersona: "orchestration.removeSourceAgentPersona", - removeAgentPersona: "orchestration.removeAgentPersona", - setImportedAgentPersonaEnabled: "orchestration.setImportedAgentPersonaEnabled", getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", searchThreads: "orchestration.searchThreads", @@ -2677,34 +2666,6 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2PublicCommand, output: OrchestrationV2DispatchCommandResult, }, - editImportedAgentPersona: { - input: AgentPersonaEditInput, - output: Schema.Void, - }, - importAgentPersonas: { - input: AgentPersonaImportInput, - output: Schema.Struct({ importedIds: Schema.Array(AgentPersonaId) }), - }, - setImportedAgentPersonaEnabled: { - input: Schema.Struct({ personaId: AgentPersonaId, enabled: Schema.Boolean }), - output: Schema.Void, - }, - removeImportedAgentPersona: { - input: Schema.Struct({ personaId: AgentPersonaId }), - output: Schema.Void, - }, - removeSourceAgentPersona: { - input: Schema.Struct({ personaId: AgentPersonaId }), - output: Schema.Void, - }, - removeAgentPersona: { - input: Schema.Struct({ personaId: AgentPersonaId }), - output: Schema.Void, - }, - getAgentPersonaCatalog: { - input: Schema.Struct({}), - output: OrchestrationV2AgentPersonaCatalog, - }, getTurnDiff: { input: OrchestrationGetTurnDiffInput, output: OrchestrationGetTurnDiffResult, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 4bdc252b354c5..faa2721f05485 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,4 +1,3 @@ -import { AgentPersonaCatalogError, AgentPersonaImportConflictError } from "./j5/agentPersona.ts"; import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; @@ -91,6 +90,7 @@ import { ProviderUploadFeedbackInput, ProviderUploadFeedbackResult, } from "./provider.ts"; +import { J5AgentPersonaRpcGroup } from "./j5/agentPersona.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; import { PullRequestActionInput, @@ -1112,71 +1112,6 @@ export const WsOrchestrationV2DispatchCommandRpc = Rpc.make( }, ); -export const WsOrchestrationV2GetAgentPersonaCatalogRpc = Rpc.make( - ORCHESTRATION_V2_WS_METHODS.getAgentPersonaCatalog, - { - payload: OrchestrationV2RpcSchemas.getAgentPersonaCatalog.input, - success: OrchestrationV2RpcSchemas.getAgentPersonaCatalog.output, - error: Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]), - }, -); - -export const WsOrchestrationV2ImportAgentPersonasRpc = Rpc.make( - ORCHESTRATION_V2_WS_METHODS.importAgentPersonas, - { - payload: OrchestrationV2RpcSchemas.importAgentPersonas.input, - success: OrchestrationV2RpcSchemas.importAgentPersonas.output, - error: Schema.Union([ - EnvironmentAuthorizationError, - AgentPersonaCatalogError, - AgentPersonaImportConflictError, - ]), - }, -); -export const WsOrchestrationV2SetImportedAgentPersonaEnabledRpc = Rpc.make( - ORCHESTRATION_V2_WS_METHODS.setImportedAgentPersonaEnabled, - { - payload: OrchestrationV2RpcSchemas.setImportedAgentPersonaEnabled.input, - success: OrchestrationV2RpcSchemas.setImportedAgentPersonaEnabled.output, - error: Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]), - }, -); -export const WsOrchestrationV2RemoveImportedAgentPersonaRpc = Rpc.make( - ORCHESTRATION_V2_WS_METHODS.removeImportedAgentPersona, - { - payload: OrchestrationV2RpcSchemas.removeImportedAgentPersona.input, - success: OrchestrationV2RpcSchemas.removeImportedAgentPersona.output, - error: Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]), - }, -); - -export const WsOrchestrationV2RemoveSourceAgentPersonaRpc = Rpc.make( - ORCHESTRATION_V2_WS_METHODS.removeSourceAgentPersona, - { - payload: OrchestrationV2RpcSchemas.removeSourceAgentPersona.input, - success: OrchestrationV2RpcSchemas.removeSourceAgentPersona.output, - error: Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]), - }, -); - -export const WsOrchestrationV2RemoveAgentPersonaRpc = Rpc.make( - ORCHESTRATION_V2_WS_METHODS.removeAgentPersona, - { - payload: OrchestrationV2RpcSchemas.removeAgentPersona.input, - success: OrchestrationV2RpcSchemas.removeAgentPersona.output, - error: Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]), - }, -); - -export const WsOrchestrationV2EditImportedAgentPersonaRpc = Rpc.make( - ORCHESTRATION_V2_WS_METHODS.editImportedAgentPersona, - { - payload: OrchestrationV2RpcSchemas.editImportedAgentPersona.input, - success: OrchestrationV2RpcSchemas.editImportedAgentPersona.output, - error: Schema.Union([EnvironmentAuthorizationError, AgentPersonaCatalogError]), - }, -); - export const WsOrchestrationV2GetTurnDiffRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.getTurnDiff, { payload: OrchestrationV2RpcSchemas.getTurnDiff.input, success: OrchestrationV2RpcSchemas.getTurnDiff.output, @@ -1483,13 +1418,6 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeBackgroundPolicyRpc, WsSubscribeResourceTelemetryRpc, WsOrchestrationV2DispatchCommandRpc, - WsOrchestrationV2GetAgentPersonaCatalogRpc, - WsOrchestrationV2ImportAgentPersonasRpc, - WsOrchestrationV2RemoveImportedAgentPersonaRpc, - WsOrchestrationV2RemoveSourceAgentPersonaRpc, - WsOrchestrationV2RemoveAgentPersonaRpc, - WsOrchestrationV2EditImportedAgentPersonaRpc, - WsOrchestrationV2SetImportedAgentPersonaEnabledRpc, WsOrchestrationV2GetWorkflowScriptRpc, WsOrchestrationV2GetTurnDiffRpc, WsOrchestrationV2GetFullThreadDiffRpc, @@ -1500,4 +1428,4 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2SubscribeArchivedShellRpc, WsOrchestrationV2SubscribeShellRpc, WsOrchestrationV2SubscribeThreadRpc, -); +).merge(J5AgentPersonaRpcGroup); From 2a3feea2d3d2e57d8956878b161684ea5bef2b69 Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Wed, 9 Sep 2026 08:45:44 -0400 Subject: [PATCH 4/7] refactor(agents): route persona launch and dispatch guards through J5 delegates The orchestrator's thread.create validation, route-lock checks, and the launch service's persona resolution now call small J5-owned helpers in apps/server/src/j5/agents/agentPersonaOrchestration.ts; the runtime policy seam calls one J5 resolver. Behavior and error messages are unchanged. Co-Authored-By: Claude Fable 5.1 --- .../j5/agents/agentPersonaOrchestration.ts | 127 ++++++++++++++++++ .../src/j5/agents/agentPersonaRuntime.ts | 16 +++ .../src/orchestration-v2/Orchestrator.ts | 93 +++++-------- .../src/orchestration-v2/RuntimePolicy.ts | 11 +- .../orchestration-v2/ThreadLaunchService.ts | 39 ++---- 5 files changed, 188 insertions(+), 98 deletions(-) create mode 100644 apps/server/src/j5/agents/agentPersonaOrchestration.ts diff --git a/apps/server/src/j5/agents/agentPersonaOrchestration.ts b/apps/server/src/j5/agents/agentPersonaOrchestration.ts new file mode 100644 index 0000000000000..2bba33eaa24c5 --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaOrchestration.ts @@ -0,0 +1,127 @@ +import type { + ModelSelection, + OrchestrationV2AgentPersonaAssignment, + OrchestrationV2AgentPersonaRequest, + OrchestrationV2AppThread, + OrchestrationV2Command, + OrchestrationV2DomainEvent, + ProviderInstanceId, + ServerProvider, +} from "@t3tools/contracts"; +import { modelSelectionsEqual } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { validateAgentPersonaAssignment } from "./agentPersonaAssignment.ts"; +import { prepareAgentPersonaLaunch } from "./agentPersonaLaunch.ts"; +import { AgentPersonaLibraryError, type createAgentPersonaLibrary } from "./agentPersonaLibrary.ts"; + +type Library = ReturnType; +type PersonaThread = Pick; + +const isLibraryError = Schema.is(AgentPersonaLibraryError); +const libraryError = (cause: unknown) => + isLibraryError(cause) ? cause : new AgentPersonaLibraryError({ message: String(cause), cause }); + +/** + * `thread.create` guard: a persona assignment must reference an intact snapshot, the + * selected provider instance must run the resolved driver, and the model selection must + * be the resolved route. Failures surface as `AgentPersonaLibraryError`; `driverFor` + * errors pass through untouched. + */ +export const guardAgentPersonaThreadCreate = ( + command: Extract, + library: Library, + driverFor: (instanceId: ProviderInstanceId) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const assignment = command.agentPersonaAssignment; + if (assignment === undefined) return; + const definition = yield* library.readSnapshot(assignment).pipe(Effect.mapError(libraryError)); + const invalid = validateAgentPersonaAssignment(assignment, definition); + if (invalid !== undefined) return yield* new AgentPersonaLibraryError({ message: invalid }); + if ((yield* driverFor(command.modelSelection.instanceId)) !== assignment.resolvedDriver) { + return yield* new AgentPersonaLibraryError({ + message: "Agent persona assignment provider instance does not match its resolved driver.", + }); + } + if (!modelSelectionsEqual(command.modelSelection, assignment.resolvedModelSelection)) { + return yield* new AgentPersonaLibraryError({ + message: "Agent persona assignment must match the thread model selection.", + }); + } + }); + +const immutableRoute = (thread: PersonaThread) => + `Agent persona thread ${thread.id} has an immutable model route.`; + +/** Persona threads keep their launch route; explicit model or provider changes are rejected. */ +export const agentPersonaRouteLockedError = ( + thread: PersonaThread, + commandType: string, +): string | undefined => + thread.agentPersonaAssignment !== undefined && + (commandType === "thread.model-selection.set" || commandType === "provider.switch") + ? immutableRoute(thread) + : undefined; + +/** A message carrying a different model selection than the resolved route is rejected too. */ +export const agentPersonaModelMismatchError = ( + thread: PersonaThread, + modelSelection: ModelSelection | undefined, +): string | undefined => + thread.agentPersonaAssignment !== undefined && + modelSelection !== undefined && + !modelSelectionsEqual(modelSelection, thread.agentPersonaAssignment.resolvedModelSelection) + ? immutableRoute(thread) + : undefined; + +export interface AgentPersonaLaunch { + readonly modelSelection: ModelSelection; + readonly agentPersonaAssignment?: OrchestrationV2AgentPersonaAssignment; +} + +/** + * Resolve the persona route before the durable create. Replays (a launch receipt exists) + * keep the requested selection; the stored create event is the source of truth then. + */ +export const resolveAgentPersonaLaunch = Effect.fn("j5.resolveAgentPersonaLaunch")(function* ( + input: { + readonly reuseExistingThread?: boolean | undefined; + readonly agentPersona?: OrchestrationV2AgentPersonaRequest | undefined; + readonly modelSelection: ModelSelection; + }, + options: { + readonly replay: boolean; + readonly providers: Effect.Effect>; + readonly library: Library; + }, +) { + const requested: AgentPersonaLaunch = { modelSelection: input.modelSelection }; + if (input.agentPersona === undefined) return requested; + if (input.reuseExistingThread === true) { + return yield* new AgentPersonaLibraryError({ + message: "Agent persona assignment requires a newly created thread.", + }); + } + if (options.replay) return requested; + const agentPersonaAssignment = yield* prepareAgentPersonaLaunch( + input.agentPersona, + yield* options.providers, + options.library, + ); + const resolved: AgentPersonaLaunch = { + modelSelection: agentPersonaAssignment.resolvedModelSelection, + agentPersonaAssignment, + }; + return resolved; +}); + +/** After the durable create, the stored event's selection wins over the requested one. */ +export const durableLaunchModelSelection = ( + storedEvents: ReadonlyArray<{ readonly event: OrchestrationV2DomainEvent }>, + fallback: ModelSelection, +): ModelSelection => { + const created = storedEvents.find((stored) => stored.event.type === "thread.created")?.event; + return created?.type === "thread.created" ? created.payload.modelSelection : fallback; +}; diff --git a/apps/server/src/j5/agents/agentPersonaRuntime.ts b/apps/server/src/j5/agents/agentPersonaRuntime.ts index 5ae5e8e21c869..8ca48c33dcf1c 100644 --- a/apps/server/src/j5/agents/agentPersonaRuntime.ts +++ b/apps/server/src/j5/agents/agentPersonaRuntime.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import type { OrchestrationV2AppThread } from "@t3tools/contracts"; +import { ProviderAdapterV2RuntimePolicy } from "../../orchestration-v2/ProviderAdapter.ts"; import { getBuiltInAgentPersonaInstructions } from "./agentPersonaPrompts.ts"; import { AgentPersonaLibraryError, type createAgentPersonaLibrary } from "./agentPersonaLibrary.ts"; import { getAgentAuthorityRules } from "./agentPersonas.ts"; @@ -47,3 +48,18 @@ export const resolveAgentPersonaRuntime = Effect.fn("resolveAgentPersonaRuntime" ...(instructions === undefined ? {} : { agentPersonaInstructions: instructions }), }; }); + +/** The thread's full runtime policy: persona sandbox and instructions when assigned, the plain mode otherwise. */ +export const resolveAgentPersonaRuntimePolicy = ( + input: { readonly thread: OrchestrationV2AppThread; readonly cwd: string | null }, + library: ReturnType, +) => + resolveAgentPersonaRuntime(input.thread, library).pipe( + Effect.map((policy) => + ProviderAdapterV2RuntimePolicy.make({ + ...policy, + interactionMode: input.thread.interactionMode, + cwd: input.cwd, + }), + ), + ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e2ea49dd633db..84a0568e45584 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -40,7 +40,11 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { makeAgentPersonaLibrary } from "../j5/agents/agentPersonaLibrary.ts"; -import { validateAgentPersonaAssignment } from "../j5/agents/agentPersonaAssignment.ts"; +import { + agentPersonaModelMismatchError, + agentPersonaRouteLockedError, + guardAgentPersonaThreadCreate, +} from "../j5/agents/agentPersonaOrchestration.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2 } from "./CommandPolicy.ts"; import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; @@ -1345,61 +1349,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command: Extract, events: Ref.Ref>, ) { - const assignmentError = - command.agentPersonaAssignment === undefined - ? undefined - : yield* personaLibrary.readSnapshot(command.agentPersonaAssignment).pipe( - Effect.map((definition) => - validateAgentPersonaAssignment(command.agentPersonaAssignment!, definition), - ), - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause, - }), - ), - ); - if (assignmentError !== undefined) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: assignmentError, - }); - } - if (command.agentPersonaAssignment !== undefined) { - const adapter = yield* providerAdapters.get(command.modelSelection.instanceId).pipe( + yield* guardAgentPersonaThreadCreate(command, personaLibrary, (providerInstanceId) => + providerAdapters.get(providerInstanceId).pipe( + Effect.map((adapter) => adapter.driver), Effect.mapError( (cause) => new OrchestratorProviderAdapterError({ commandId: command.commandId, - providerInstanceId: command.modelSelection.instanceId, + providerInstanceId, cause, }), ), - ); - if (adapter.driver !== command.agentPersonaAssignment.resolvedDriver) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "Agent persona assignment provider instance does not match its resolved driver.", - }); - } - } - if ( - command.agentPersonaAssignment !== undefined && - !modelSelectionsEqual( - command.modelSelection, - command.agentPersonaAssignment.resolvedModelSelection, - ) - ) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "Agent persona assignment must match the thread model selection.", - }); - } + ), + ).pipe( + Effect.catchTag( + "AgentPersonaLibraryError", + (cause) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ), + ); yield* Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -1589,14 +1561,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is not pinned and cannot be reordered.`, }); } - if ( - thread.agentPersonaAssignment !== undefined && - (command.type === "thread.model-selection.set" || command.type === "provider.switch") - ) { + const personaRouteLocked = agentPersonaRouteLockedError(thread, command.type); + if (personaRouteLocked !== undefined) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Agent persona thread ${command.threadId} has an immutable model route.`, + cause: personaRouteLocked, }); } if ( @@ -2957,18 +2927,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } } - if ( - projection.thread.agentPersonaAssignment !== undefined && - command.modelSelection !== undefined && - !modelSelectionsEqual( - command.modelSelection, - projection.thread.agentPersonaAssignment.resolvedModelSelection, - ) - ) { + const personaModelMismatch = agentPersonaModelMismatchError( + projection.thread, + command.modelSelection, + ); + if (personaModelMismatch !== undefined) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Agent persona thread ${command.threadId} has an immutable model route.`, + cause: personaModelMismatch, }); } if (projection.thread.settledOverride !== null) { diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.ts b/apps/server/src/orchestration-v2/RuntimePolicy.ts index 8332e4fb2aed0..7addfb364a9c7 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.ts @@ -15,7 +15,7 @@ import { makeAgentPersonaLibrary, type createAgentPersonaLibrary, } from "../j5/agents/agentPersonaLibrary.ts"; -import { resolveAgentPersonaRuntime } from "../j5/agents/agentPersonaRuntime.ts"; +import { resolveAgentPersonaRuntimePolicy } from "../j5/agents/agentPersonaRuntime.ts"; import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, @@ -66,14 +66,7 @@ const runtimePolicyForThread = ( input: { readonly thread: OrchestrationV2AppThread; readonly cwd: string | null }, library: ReturnType, ) => - resolveAgentPersonaRuntime(input.thread, library).pipe( - Effect.map((policy) => - ProviderAdapterV2RuntimePolicy.make({ - ...policy, - interactionMode: input.thread.interactionMode, - cwd: input.cwd, - }), - ), + resolveAgentPersonaRuntimePolicy(input, library).pipe( Effect.mapError( (cause) => new RuntimePolicyResolveError({ diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 17db07882d612..31cabaf4bbf97 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -4,7 +4,6 @@ import { type MessageId, type ModelSelection, type OrchestrationV2Actor, - type OrchestrationV2AgentPersonaAssignment, type OrchestrationV2AgentPersonaRequest, type OrchestrationV2CreationSource, type OrchestrationV2ThreadProjection, @@ -36,7 +35,10 @@ import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.t import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; -import { prepareAgentPersonaLaunch } from "../j5/agents/agentPersonaLaunch.ts"; +import { + durableLaunchModelSelection, + resolveAgentPersonaLaunch, +} from "../j5/agents/agentPersonaOrchestration.ts"; import { makeAgentPersonaLibrary } from "../j5/agents/agentPersonaLibrary.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; @@ -474,24 +476,13 @@ export const make = Effect.gen(function* () { "update-thread", )("Reusing an existing thread requires a thread id."); } - if (input.reuseExistingThread === true && input.agentPersona !== undefined) { - return yield* mapError( - input, - "resolve-agent-persona", - )("Agent persona assignment requires a newly created thread."); - } const launchReceipt = yield* readReceipt(input, input.commandId); - let launchModelSelection = input.modelSelection; - let agentPersonaAssignment: OrchestrationV2AgentPersonaAssignment | undefined; - if (input.agentPersona !== undefined && Option.isNone(launchReceipt)) { - agentPersonaAssignment = yield* prepareAgentPersonaLaunch( - input.agentPersona, - yield* providerRegistry.getProviders, - personaLibrary, - ).pipe(Effect.mapError(mapError(input, "resolve-agent-persona"))); - launchModelSelection = agentPersonaAssignment.resolvedModelSelection; - } + const personaLaunch = yield* resolveAgentPersonaLaunch(input, { + replay: Option.isSome(launchReceipt), + providers: providerRegistry.getProviders, + library: personaLibrary, + }).pipe(Effect.mapError(mapError(input, "resolve-agent-persona"))); return yield* Effect.gen(function* () { const candidateThreadId = input.threadId ?? @@ -521,10 +512,9 @@ export const make = Effect.gen(function* () { threadId: candidateThreadId, projectId: input.projectId, title: input.title, - modelSelection: launchModelSelection, + ...personaLaunch, runtimeMode: input.runtimeMode, interactionMode: input.interactionMode, - ...(agentPersonaAssignment === undefined ? {} : { agentPersonaAssignment }), branch: initialBranch, worktreePath: initialWorktreePath, createdBy: input.createdBy, @@ -542,13 +532,10 @@ export const make = Effect.gen(function* () { const threadId = claimed.storedEvents.find((stored) => stored.event.type.startsWith("thread."))?.event .threadId ?? candidateThreadId; - const durableThread = claimed.storedEvents.find( - (stored) => stored.event.type === "thread.created", + const durableModelSelection = durableLaunchModelSelection( + claimed.storedEvents, + personaLaunch.modelSelection, ); - const durableModelSelection = - durableThread?.event.type === "thread.created" - ? durableThread.event.payload.modelSelection - : launchModelSelection; if (project.id !== input.projectId) { return yield* mapError(input, "resolve-project", threadId)("Project identity changed."); } From a3d958dcf53a04afda6a9fd642d91b5f41cdbbda Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Wed, 9 Sep 2026 10:25:43 -0400 Subject: [PATCH 5/7] feat(agents): accept only YAML agent definitions Source folders and imports now recognize .yaml/.yml files only; JSON files are ignored in folders and rejected on import. Server-written stores and launch snapshots remain JSON, so no persisted state changes. Co-Authored-By: Claude Fable 5.1 --- .../src/j5/agents/agentPersonaLibrary.test.ts | 120 ++++++++++-------- .../src/j5/agents/agentPersonaLibrary.ts | 29 +++-- .../ThreadLaunchService.test.ts | 7 +- .../src/j5/agentPersonas.test.ts | 44 ++++--- .../client-runtime/src/j5/agentPersonas.ts | 4 +- packages/contracts/src/j5/agentPersona.ts | 4 +- 6 files changed, 117 insertions(+), 91 deletions(-) diff --git a/apps/server/src/j5/agents/agentPersonaLibrary.test.ts b/apps/server/src/j5/agents/agentPersonaLibrary.test.ts index 666a5c0730495..de722b32d5e75 100644 --- a/apps/server/src/j5/agents/agentPersonaLibrary.test.ts +++ b/apps/server/src/j5/agents/agentPersonaLibrary.test.ts @@ -42,7 +42,7 @@ const fixture = Effect.gen(function* () { const write = (name: string, value: unknown) => fs .makeDirectory(folder, { recursive: true }) - .pipe(Effect.andThen(fs.writeFileString(path.join(folder, name), json(value)))); + .pipe(Effect.andThen(fs.writeFileString(path.join(folder, name), yaml(value)))); return { fs, path, stateDir, folder, library, write }; }); @@ -76,7 +76,7 @@ describe("folder-backed persona library", () => { it.effect("loads arbitrary persona ids and custom artifacts without adding built-ins", () => Effect.gen(function* () { const { library, write } = yield* fixture; - yield* write("researcher.json", custom); + yield* write("researcher.yaml", custom); assert.deepEqual(yield* library.load(), [custom]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -84,10 +84,10 @@ describe("folder-backed persona library", () => { it.effect("reads relative and absolute source folders in deterministic order", () => Effect.gen(function* () { const { library, fs, path, stateDir, folder, write } = yield* fixture; - yield* write("z.json", custom); + yield* write("z.yaml", custom); const second = path.join(stateDir, "team-library"); yield* fs.makeDirectory(second); - yield* fs.writeFileString(path.join(second, "a.json"), json({ ...custom, id: "another" })); + yield* fs.writeFileString(path.join(second, "a.yaml"), yaml({ ...custom, id: "another" })); yield* fs.writeFileString( path.join(stateDir, "agent-personas.json"), json({ folders: ["personas", second] }), @@ -104,8 +104,8 @@ describe("folder-backed persona library", () => { it.effect("rejects duplicate ids across files instead of choosing an arbitrary winner", () => Effect.gen(function* () { const { library, write } = yield* fixture; - yield* write("a.json", custom); - yield* write("b.json", { ...custom, version: 4 }); + yield* write("a.yaml", custom); + yield* write("b.yaml", { ...custom, version: 4 }); assert.include(String(yield* library.load().pipe(Effect.flip)), "Duplicate persona id"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -113,7 +113,7 @@ describe("folder-backed persona library", () => { it.effect("reports invalid files, invalid configuration, and missing configured folders", () => Effect.gen(function* () { const { library, write, fs, path, stateDir } = yield* fixture; - yield* write("invalid.json", { ...custom, outputArtifact: "UndefinedArtifact" }); + yield* write("invalid.yaml", { ...custom, outputArtifact: "UndefinedArtifact" }); assert.include(String(yield* library.load().pipe(Effect.flip)), "Invalid persona file"); yield* fs.writeFileString(path.join(stateDir, "agent-personas.json"), "not json"); assert.isDefined(yield* library.load().pipe(Effect.flip)); @@ -130,10 +130,10 @@ describe("folder-backed persona library", () => { () => Effect.gen(function* () { const { library, write, fs, folder } = yield* fixture; - yield* write("researcher.json", custom); + yield* write("researcher.yaml", custom); const assignment = assignmentFor(yield* library.snapshot(custom)); assert.isDefined(assignment.definitionDigest); - yield* write("researcher.json", { ...custom, instructions: "Changed behavior" }); + yield* write("researcher.yaml", { ...custom, instructions: "Changed behavior" }); assert.equal((yield* library.load())[0]?.instructions, "Changed behavior"); assert.deepEqual(yield* library.readSnapshot(assignment), custom); yield* fs.remove(folder, { recursive: true }); @@ -199,9 +199,9 @@ describe("folder-backed persona library", () => { }); describe("imported persona library", () => { - const file = (definition: typeof custom, name = "agent.json") => ({ + const file = (definition: typeof custom, name = "agent.yaml") => ({ name, - content: json(definition), + content: yaml(definition), }); it.effect("imports a nested folder batch and retains it across library instances", () => @@ -209,7 +209,7 @@ describe("imported persona library", () => { const { library, fs, path, stateDir } = yield* fixture; const other = { ...custom, id: "second-agent" }; const result = yield* library.importFiles({ - files: [file(custom, "team/researcher/agent.json"), file(other, "team/second/agent.json")], + files: [file(custom, "team/researcher/agent.yaml"), file(other, "team/second/agent.yaml")], replaceExisting: false, }); assert.deepEqual(result.importedIds, [custom.id, other.id]); @@ -233,12 +233,12 @@ describe("imported persona library", () => { .importFiles({ files: [ file({ ...custom, id: "valid-new-agent" }), - { name: "broken/agent.json", content: "{}" }, + { name: "broken/agent.yaml", content: "{}" }, ], replaceExisting: false, }) .pipe(Effect.flip); - assert.include(String(error), "broken/agent.json"); + assert.include(String(error), "broken/agent.yaml"); assert.deepEqual(yield* library.load(), before); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -246,7 +246,7 @@ describe("imported persona library", () => { it.effect("requires explicit replacement and restores the source on removal", () => Effect.gen(function* () { const { library, write } = yield* fixture; - yield* write("agent.json", custom); + yield* write("agent.yaml", custom); const changed = { ...custom, instructions: "Updated instructions" }; const input = { files: [file(changed)], replaceExisting: false }; assert.include(String(yield* library.importFiles(input).pipe(Effect.flip)), "already exist"); @@ -262,8 +262,8 @@ describe("imported persona library", () => { Effect.gen(function* () { const { library } = yield* fixture; for (const files of [ - [file(custom, "a/agent.json"), file(custom, "b/agent.json")], - [{ name: "large.json", content: "é".repeat(32769) }], + [file(custom, "a/agent.yaml"), file(custom, "b/agent.yaml")], + [{ name: "large.yaml", content: "é".repeat(32769) }], [], Array.from({ length: 51 }, (_, i) => file({ ...custom, id: `agent-${i}` })), ]) { @@ -321,7 +321,7 @@ describe("imported persona library", () => { it.effect("preserves disabled state on replacement and clears it on removal", () => Effect.gen(function* () { const { library, write } = yield* fixture; - yield* write("source.json", custom); + yield* write("source.yaml", custom); yield* library.importFiles({ files: [file(custom)], replaceExisting: true }); yield* library.setImportedEnabled(custom.id, false); yield* library.importFiles({ @@ -386,8 +386,8 @@ describe("removing folder-loaded agents", () => { Effect.gen(function* () { const { library, write, fs, path, stateDir, folder } = yield* fixture; const scout = { ...custom, displayName: "My Local Scout" }; - yield* write("local-scout.json", scout); - yield* write("other.json", { ...custom, id: "other-agent" }); + yield* write("local-scout.yaml", scout); + yield* write("other.yaml", { ...custom, id: "other-agent" }); const assignment = { ...assignmentFor(yield* library.snapshot(scout)), displayName: scout.displayName, @@ -398,14 +398,14 @@ describe("removing folder-loaded agents", () => { (yield* restarted.load()).map(({ id }) => id), ["other-agent"], ); - assert.equal(yield* fs.readFileString(path.join(folder, "local-scout.json")), json(scout)); + assert.equal(yield* fs.readFileString(path.join(folder, "local-scout.yaml")), yaml(scout)); const error = yield* prepareAgentPersonaLaunch({ personaId: scout.id }, [], restarted).pipe( Effect.flip, ); assert.include(String(error), "Unknown agent persona"); assert.deepEqual(yield* restarted.readSnapshot(assignment), scout); yield* restarted.importFiles({ - files: [{ name: "local-scout.json", content: json(scout) }], + files: [{ name: "local-scout.yaml", content: yaml(scout) }], replaceExisting: false, }); assert.isTrue((yield* restarted.load()).some(({ id }) => id === scout.id)); @@ -427,7 +427,7 @@ describe("removing folder-loaded agents", () => { assert.notInclude(remaining, "scout"); assert.notInclude(remaining, "builder"); yield* library.importFiles({ - files: [{ name: "agent.json", content: json(custom) }], + files: [{ name: "agent.yaml", content: yaml(custom) }], replaceExisting: false, }); assert.include( @@ -446,10 +446,10 @@ describe("complete agent removal", () => { Effect.gen(function* () { for (const mode of ["source", "import", "override"] as const) { const { library, write, fs, path, stateDir, folder } = yield* fixture; - if (mode !== "import") yield* write("agent.json", custom); + if (mode !== "import") yield* write("agent.yaml", custom); if (mode !== "source") { yield* library.importFiles({ - files: [{ name: "agent.json", content: json(custom) }], + files: [{ name: "agent.yaml", content: yaml(custom) }], replaceExisting: true, }); yield* library.setImportedEnabled(custom.id, false); @@ -463,10 +463,10 @@ describe("complete agent removal", () => { assert.notInclude(catalog.disabledIds, custom.id); assert.deepEqual(yield* restarted.readSnapshot(assignment), custom); if (mode !== "import") - assert.equal(yield* fs.readFileString(path.join(folder, "agent.json")), json(custom)); + assert.equal(yield* fs.readFileString(path.join(folder, "agent.yaml")), yaml(custom)); yield* restarted.removeAgent(custom.id); yield* restarted.importFiles({ - files: [{ name: "agent.json", content: json(custom) }], + files: [{ name: "agent.yaml", content: yaml(custom) }], replaceExisting: false, }); assert.isTrue((yield* restarted.load()).some(({ id }) => id === custom.id)); @@ -490,9 +490,9 @@ describe("editing imported agents", () => { () => Effect.gen(function* () { const { library, write, fs, path, stateDir, folder } = yield* fixture; - yield* write("agent.json", custom); + yield* write("agent.yaml", custom); yield* library.importFiles({ - files: [{ name: "agent.json", content: json(custom) }], + files: [{ name: "agent.yaml", content: yaml(custom) }], replaceExisting: true, }); const assignment = assignmentFor(yield* library.snapshot(custom)); @@ -520,7 +520,7 @@ describe("editing imported agents", () => { modelRoute: update.modelRoute, }); assert.include(catalog.disabledIds, custom.id); - assert.equal(yield* fs.readFileString(path.join(folder, "agent.json")), json(custom)); + assert.equal(yield* fs.readFileString(path.join(folder, "agent.yaml")), yaml(custom)); assert.deepEqual(yield* restarted.readSnapshot(assignment), custom); assert.notEqual(definitionDigest(changed), assignment.definitionDigest); yield* restarted.setImportedEnabled(custom.id, true); @@ -537,7 +537,7 @@ describe("editing imported agents", () => { Effect.gen(function* () { const { library } = yield* fixture; yield* library.importFiles({ - files: [{ name: "agent.json", content: json(custom) }], + files: [{ name: "agent.yaml", content: yaml(custom) }], replaceExisting: false, }); for (const input of [ @@ -581,10 +581,10 @@ describe("editing imported agents", () => { allowedPolicies: ["read-only", "workspace-write"], }, }); - yield* write("agent.json", definition); + yield* write("agent.yaml", definition); yield* library.editImported(edit(definition)).pipe(Effect.flip); yield* library.importFiles({ - files: [{ name: "agent.json", content: json(definition) }], + files: [{ name: "agent.yaml", content: yaml(definition) }], replaceExisting: true, }); yield* library.editImported({ ...edit(definition), displayName: "New name" }); @@ -598,15 +598,15 @@ describe("confirmed import replacement", () => { Effect.gen(function* () { const { library } = yield* fixture; yield* library.importFiles({ - files: [{ name: "agent.json", content: json(custom) }], + files: [{ name: "agent.yaml", content: yaml(custom) }], replaceExisting: false, }); yield* library.setImportedEnabled(custom.id, false); const assignment = assignmentFor(yield* library.snapshot(custom)); const replacement = { ...custom, version: custom.version + 1, displayName: "Company update" }; const files = [ - { name: "updated/agent.json", content: json(replacement) }, - { name: "new/agent.json", content: json({ ...custom, id: "new-agent" }) }, + { name: "updated/agent.yaml", content: yaml(replacement) }, + { name: "new/agent.yaml", content: yaml({ ...custom, id: "new-agent" }) }, ]; const conflict = yield* library .importFiles({ files, replaceExisting: false }) @@ -644,12 +644,12 @@ describe("confirmed import replacement", () => { Effect.gen(function* () { const { library } = yield* fixture; yield* library.importFiles({ - files: [{ name: "agent.json", content: json(custom) }], + files: [{ name: "agent.yaml", content: yaml(custom) }], replaceExisting: false, }); const files = [ - { name: "agent.json", content: json({ ...custom, version: 10 }) }, - { name: "new.json", content: json({ ...custom, id: "new-agent" }) }, + { name: "agent.yaml", content: yaml({ ...custom, version: 10 }) }, + { name: "new.yaml", content: yaml({ ...custom, id: "new-agent" }) }, ]; const original = yield* library .importFiles({ files, replaceExisting: false }) @@ -666,7 +666,7 @@ describe("confirmed import replacement", () => { yield* library.importFiles({ files: [ { - name: "new.json", + name: "new.yaml", content: json({ ...custom, id: "new-agent", displayName: "Another import" }), }, ], @@ -701,7 +701,7 @@ describe("selective import replacement", () => { const kept = { ...custom, id: "keep-agent", displayName: "Local edits" }; yield* library.importFiles({ files: [custom, kept].map((value) => ({ - name: `${value.id}/agent.json`, + name: `${value.id}/agent.yaml`, content: json(value), })), replaceExisting: false, @@ -711,7 +711,7 @@ describe("selective import replacement", () => { { ...custom, version: 10 }, { ...kept, version: 10, displayName: "Company update" }, { ...custom, id: "new-agent" }, - ].map((value) => ({ name: `${value.id}/agent.json`, content: json(value) })); + ].map((value) => ({ name: `${value.id}/agent.yaml`, content: yaml(value) })); const conflict = yield* library .importFiles({ files, replaceExisting: false }) .pipe(Effect.flip); @@ -745,7 +745,7 @@ describe("selective import replacement", () => { it.effect("does not restore a skipped agent removed while confirmation was open", () => Effect.gen(function* () { const { library } = yield* fixture; - const files = [{ name: "agent.json", content: json(custom) }]; + const files = [{ name: "agent.yaml", content: yaml(custom) }]; yield* library.importFiles({ files, replaceExisting: false }); yield* library.removeAgent(custom.id); const result = yield* library.importFiles({ @@ -762,7 +762,7 @@ describe("selective import replacement", () => { describe("YAML agent definitions", () => { it.effect( - "imports YAML and JSON together and preserves literal instructions in JSON storage and snapshots", + "imports every YAML extension and preserves literal instructions in JSON storage and snapshots", () => Effect.gen(function* () { const { library, fs, path, stateDir } = yield* fixture; @@ -774,7 +774,7 @@ describe("YAML agent definitions", () => { const result = yield* library.importFiles({ files: [ { name: "team/agent.YAML", content }, - { name: "team/other.json", content: json({ ...custom, id: "other" }) }, + { name: "team/other.yaml", content: yaml({ ...custom, id: "other" }) }, { name: "team/third.yml", content: yaml({ ...custom, id: "third" }) }, ], replaceExisting: false, @@ -790,7 +790,7 @@ describe("YAML agent definitions", () => { assert.isTrue(stored.trimStart().startsWith("[")); const conflict = yield* library .importFiles({ - files: [{ name: "agent.json", content: json(definition) }], + files: [{ name: "agent.yaml", content: yaml(definition) }], replaceExisting: false, }) .pipe(Effect.flip); @@ -806,13 +806,13 @@ describe("YAML agent definitions", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("loads YAML source folders and rejects duplicate IDs across formats", () => + it.effect("loads YAML source folders and rejects duplicate IDs across extensions", () => Effect.gen(function* () { const { library, fs, path, folder, write } = yield* fixture; yield* fs.makeDirectory(folder, { recursive: true }); yield* fs.writeFileString(path.join(folder, "agent.yml"), yaml(custom)); assert.deepEqual(yield* library.load(), [custom]); - yield* write("agent.json", custom); + yield* write("agent.yaml", custom); assert.include(String(yield* library.load().pipe(Effect.flip)), "Duplicate persona id"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -832,7 +832,7 @@ describe("YAML agent definitions", () => { const error = yield* library .importFiles({ files: [ - { name: "valid.json", content: json({ ...custom, id: "valid" }) }, + { name: "valid.yaml", content: yaml({ ...custom, id: "valid" }) }, { name: "invalid.yaml", content }, ], replaceExisting: false, @@ -844,7 +844,7 @@ describe("YAML agent definitions", () => { const duplicate = yield* library .importFiles({ files: [ - { name: "agent.json", content: json(custom) }, + { name: "agent.yaml", content: yaml(custom) }, { name: "agent.yaml", content: yaml(custom) }, ], replaceExisting: false, @@ -853,4 +853,24 @@ describe("YAML agent definitions", () => { assert.include(String(duplicate), "Multiple selected files"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("rejects JSON imports and ignores JSON files in source folders", () => + Effect.gen(function* () { + const { library, fs, path, folder } = yield* fixture; + const error = yield* library + .importFiles({ + files: [{ name: "team/agent.json", content: json(custom) }], + replaceExisting: false, + }) + .pipe(Effect.flip); + assert.include(String(error), "Unsupported agent file: team/agent.json"); + yield* fs.makeDirectory(folder, { recursive: true }); + yield* fs.writeFileString(path.join(folder, "legacy.json"), json(custom)); + yield* fs.writeFileString(path.join(folder, "agent.yaml"), yaml({ ...custom, id: "kept" })); + assert.deepEqual( + (yield* library.load()).map(({ id }) => id), + ["kept"], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/j5/agents/agentPersonaLibrary.ts b/apps/server/src/j5/agents/agentPersonaLibrary.ts index 7f994f8ad7866..3e4a757e0af6c 100644 --- a/apps/server/src/j5/agents/agentPersonaLibrary.ts +++ b/apps/server/src/j5/agents/agentPersonaLibrary.ts @@ -34,18 +34,21 @@ export class AgentPersonaLibraryError extends Schema.TaggedErrorClass decodeAgentPersonaDefinition(definition)); +}); +/** User-authored definitions (source folders and imports) are YAML documents. */ const decodeDefinition = Effect.fn("AgentPersonaLibrary.decodeDefinition")(function* ( text: string, - name = "agent.json", ) { - const definition = /\.ya?ml$/i.test(name) - ? yield* Effect.try((): unknown => { - const document = parseDocument(text, { version: "1.2", uniqueKeys: true }); - const issue = document.errors[0] ?? document.warnings[0]; - if (issue) throw issue; - return document.toJS({ maxAliasCount: 0 }); - }) - : yield* decodeDefinitionJson(text); + const definition = yield* Effect.try((): unknown => { + const document = parseDocument(text, { version: "1.2", uniqueKeys: true }); + const issue = document.errors[0] ?? document.warnings[0]; + if (issue) throw issue; + return document.toJS({ maxAliasCount: 0 }); + }); return yield* Effect.try(() => decodeAgentPersonaDefinition(definition)); }); const ImportedDefinition = Schema.Struct({ @@ -110,7 +113,7 @@ export function createAgentPersonaLibrary(storage?: { message: `Persona file exceeds 64 KiB: ${file}`, }); const text = yield* fs.readFileString(file); - const definition = yield* decodeDefinition(text, name).pipe( + const definition = yield* decodeDefinition(text).pipe( Effect.mapError( (cause) => new AgentPersonaLibraryError({ message: `Invalid persona file ${file}`, cause }), @@ -202,7 +205,7 @@ export function createAgentPersonaLibrary(storage?: { ) { if (input.files.length === 0 || input.files.length > AGENT_PERSONA_IMPORT_MAX_FILES) return yield* new AgentPersonaLibraryError({ - message: `Select between 1 and ${AGENT_PERSONA_IMPORT_MAX_FILES} JSON or YAML files.`, + message: `Select between 1 and ${AGENT_PERSONA_IMPORT_MAX_FILES} YAML files.`, }); const incoming = new Map(); for (const file of input.files) { @@ -212,7 +215,7 @@ export function createAgentPersonaLibrary(storage?: { }); if (new TextEncoder().encode(file.content).byteLength > AGENT_PERSONA_IMPORT_MAX_BYTES) return yield* new AgentPersonaLibraryError({ message: `${file.name} exceeds 64 KiB.` }); - const definition = yield* decodeDefinition(file.content, file.name).pipe( + const definition = yield* decodeDefinition(file.content).pipe( Effect.mapError( (cause) => new AgentPersonaLibraryError({ @@ -404,7 +407,7 @@ export function createAgentPersonaLibrary(storage?: { const text = yield* storage.fs.readFileString( storage.path.join(storage.stateDir, "agent-persona-snapshots", `${digest}.json`), ); - const definition = yield* decodeDefinition(text); + const definition = yield* decodeSnapshot(text); if ( definitionDigest(definition) !== digest || definition.id !== assignment.personaId || diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index b71327f7caca2..4683fec8ee40f 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -22,6 +22,7 @@ import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import { stringify as yamlStringify } from "yaml"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -1608,7 +1609,7 @@ it.effect("does not depend on the legacy launch workflow table", () => { }).pipe(Effect.provide(harness.layer)); }); -const jsonPersonaFixture = (value: unknown) => JSON.stringify(value); +const yamlPersonaFixture = (value: unknown) => yamlStringify(value); it.effect( "launches an imported persona and replays its receipt after the source is removed", @@ -1660,7 +1661,7 @@ it.effect( const path = yield* Path.Path; const folder = path.join(config.stateDir, "personas"); yield* fs.makeDirectory(folder, { recursive: true }); - yield* fs.writeFileString(path.join(folder, "team.json"), jsonPersonaFixture(definition)); + yield* fs.writeFileString(path.join(folder, "team.yaml"), yamlPersonaFixture(definition)); const launches = yield* ThreadLaunch.ThreadLaunchService; const threads = yield* ThreadManagement.ThreadManagementService; const input = { @@ -1676,7 +1677,7 @@ it.effect( ); const library = yield* makeAgentPersonaLibrary; yield* library.importFiles({ - files: [{ name: "agent.json", content: jsonPersonaFixture(definition) }], + files: [{ name: "agent.yaml", content: yamlPersonaFixture(definition) }], replaceExisting: true, }); yield* library.setImportedEnabled(definition.id, false); diff --git a/packages/client-runtime/src/j5/agentPersonas.test.ts b/packages/client-runtime/src/j5/agentPersonas.test.ts index af1156802663f..19978356802ab 100644 --- a/packages/client-runtime/src/j5/agentPersonas.test.ts +++ b/packages/client-runtime/src/j5/agentPersonas.test.ts @@ -140,40 +140,42 @@ describe("persona import selection", () => { size, text: async () => content, }); - it("includes nested JSON and YAML definitions and ignores accompanying documentation", async () => { + it("includes nested YAML definitions and skips JSON files and documentation unread", async () => { + const unread = (name: string) => ({ + name, + size: 1, + text: async () => { + throw new Error("Must not read"); + }, + }); expect( await prepareAgentPersonaImport([ - file("team/one/agent.json", "one"), - file("team/two/agent.JSON", "two"), - file("team/three/agent.yaml", "three"), + file("team/one/agent.yaml", "one"), + file("team/two/agent.YAML", "two"), + file("team/three/agent.yml", "three"), file("team/four/agent.YML", "four"), - { - name: "team/README.md", - size: 1, - text: async () => { - throw new Error("Must not read"); - }, - }, + unread("team/legacy/agent.json"), + unread("team/README.md"), ]), ).toEqual([ - { name: "team/one/agent.json", content: "one" }, - { name: "team/two/agent.JSON", content: "two" }, - { name: "team/three/agent.yaml", content: "three" }, + { name: "team/one/agent.yaml", content: "one" }, + { name: "team/two/agent.YAML", content: "two" }, + { name: "team/three/agent.yml", content: "three" }, { name: "team/four/agent.YML", content: "four" }, ]); }); it("imports only the selected individual definition", async () => { - expect(await prepareAgentPersonaImport([file("agent.json", "selected")])).toEqual([ - { name: "agent.json", content: "selected" }, + expect(await prepareAgentPersonaImport([file("agent.yaml", "selected")])).toEqual([ + { name: "agent.yaml", content: "selected" }, ]); }); it("reports empty folders and file limits before reading oversized selections", async () => { - await expect(prepareAgentPersonaImport([file("README.md")])).rejects.toThrow("No JSON"); - await expect(prepareAgentPersonaImport([file("big.json", "", 65537)])).rejects.toThrow( + await expect(prepareAgentPersonaImport([file("README.md")])).rejects.toThrow("No YAML"); + await expect(prepareAgentPersonaImport([file("big.yaml", "", 65537)])).rejects.toThrow( "64 KiB", ); await expect( - prepareAgentPersonaImport(Array.from({ length: 51 }, () => file("agent.json"))), + prepareAgentPersonaImport(Array.from({ length: 51 }, () => file("agent.yaml"))), ).rejects.toThrow("at most 50"); }); it("identifies removable imports while treating older catalog entries as source definitions", () => { @@ -279,7 +281,7 @@ it("uses the selected environment's advertised models and reasoning options with }); describe("import replacement confirmation", () => { - const files = [{ name: "agent.json", content: "selected content" }]; + const files = [{ name: "agent.yaml", content: "selected content" }]; const conflict = new AgentPersonaImportConflictError({ message: "Already exists", conflicts: [{ personaId: "scout", displayName: "Scout", definitionDigest: "a".repeat(64) }], @@ -371,7 +373,7 @@ describe("import replacement confirmation", () => { }); it("retains skipped agents through fresh confirmation and accepts an empty replacement selection", async () => { - const files = [{ name: "folder/agent.json", content: "selected content" }]; + const files = [{ name: "folder/agent.yaml", content: "selected content" }]; const first = new AgentPersonaImportConflictError({ message: "Already exists", conflicts: [ diff --git a/packages/client-runtime/src/j5/agentPersonas.ts b/packages/client-runtime/src/j5/agentPersonas.ts index 49f9997de4635..374477e1a7c33 100644 --- a/packages/client-runtime/src/j5/agentPersonas.ts +++ b/packages/client-runtime/src/j5/agentPersonas.ts @@ -120,7 +120,7 @@ export function presentAgentPersonaCatalog( }); } -/** Read only selected JSON and YAML files; paths are labels, never server filesystem destinations. */ +/** Read only selected YAML files; paths are labels, never server filesystem destinations. */ export async function prepareAgentPersonaImport( files: ReadonlyArray<{ readonly name: string; @@ -130,7 +130,7 @@ export async function prepareAgentPersonaImport( ) { const definitions = files.filter((file) => isAgentPersonaDefinitionFile(file.name)); if (definitions.length === 0) - throw new Error("No JSON or YAML agent definitions found in the selection."); + throw new Error("No YAML agent definitions found in the selection."); if (definitions.length > AGENT_PERSONA_IMPORT_MAX_FILES) throw new Error( `Select at most ${AGENT_PERSONA_IMPORT_MAX_FILES} agent definitions at a time.`, diff --git a/packages/contracts/src/j5/agentPersona.ts b/packages/contracts/src/j5/agentPersona.ts index 2da933311ff09..303853601bd1d 100644 --- a/packages/contracts/src/j5/agentPersona.ts +++ b/packages/contracts/src/j5/agentPersona.ts @@ -141,8 +141,8 @@ export const OrchestrationV2AgentPersonaCatalog = Schema.Struct({ }); export type OrchestrationV2AgentPersonaCatalog = typeof OrchestrationV2AgentPersonaCatalog.Type; -export const isAgentPersonaDefinitionFile = (name: string): boolean => - /\.(json|ya?ml)$/i.test(name); +/** User-authored definitions are YAML only; internal stores and snapshots stay JSON. */ +export const isAgentPersonaDefinitionFile = (name: string): boolean => /\.ya?ml$/i.test(name); export const AGENT_PERSONA_IMPORT_MAX_FILES = 50; export const AGENT_PERSONA_IMPORT_MAX_BYTES = 65536; From 7b5353cb92f704bc3fa4f86e05dc7f0ec29076ed Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Wed, 9 Sep 2026 11:14:59 -0400 Subject: [PATCH 6/7] test(agents): move persona cases into J5 test files The launch, runtime policy, projection rebuild, contract, RPC scope, and adapter instruction cases for agent personas now live under apps/server/src/j5/agents and packages/contracts/src/j5, each with a small mirror of the upstream harness it needs. The upstream test files return to their trunk content. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/auth/RpcAuthorization.test.ts | 25 - .../agentPersonaAdapterInstructions.test.ts | 60 +++ .../agentPersonaLaunch.integration.test.ts | 508 ++++++++++++++++++ .../j5/agents/agentPersonaProjection.test.ts | 125 +++++ .../src/j5/agents/agentPersonaRpc.test.ts | 34 ++ .../agents/agentPersonaRuntimePolicy.test.ts | 166 ++++++ .../Adapters/ClaudeAdapterV2.test.ts | 16 - .../Adapters/CodexAdapterV2.test.ts | 26 - .../FoundationPersistence.test.ts | 34 -- .../orchestration-v2/RuntimePolicy.test.ts | 83 --- .../ThreadLaunchService.test.ts | 287 ---------- .../contracts/src/j5/agentPersona.test.ts | 66 +++ .../contracts/src/orchestrationV2.test.ts | 60 --- 13 files changed, 959 insertions(+), 531 deletions(-) create mode 100644 apps/server/src/j5/agents/agentPersonaAdapterInstructions.test.ts create mode 100644 apps/server/src/j5/agents/agentPersonaLaunch.integration.test.ts create mode 100644 apps/server/src/j5/agents/agentPersonaProjection.test.ts create mode 100644 apps/server/src/j5/agents/agentPersonaRpc.test.ts create mode 100644 apps/server/src/j5/agents/agentPersonaRuntimePolicy.test.ts create mode 100644 packages/contracts/src/j5/agentPersona.test.ts diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index e4238723f9c42..25971b0c0aecf 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -4,7 +4,6 @@ import { AuthRelayReadScope, AuthRelayWriteScope, WS_METHODS, - J5_AGENT_PERSONA_WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; @@ -55,30 +54,6 @@ describe("RPC authorization scopes", () => { ); }); - it("requires operate access to import, edit, toggle, or remove persona definitions", () => { - expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.editImportedAgentPersona)).toBe( - AuthOrchestrationOperateScope, - ); - expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.removeAgentPersona)).toBe( - AuthOrchestrationOperateScope, - ); - expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.removeSourceAgentPersona)).toBe( - AuthOrchestrationOperateScope, - ); - expect( - requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.setImportedAgentPersonaEnabled), - ).toBe(AuthOrchestrationOperateScope); - expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.getAgentPersonaCatalog)).toBe( - AuthOrchestrationReadScope, - ); - expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.importAgentPersonas)).toBe( - AuthOrchestrationOperateScope, - ); - expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.removeImportedAgentPersona)).toBe( - AuthOrchestrationOperateScope, - ); - }); - it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/j5/agents/agentPersonaAdapterInstructions.test.ts b/apps/server/src/j5/agents/agentPersonaAdapterInstructions.test.ts new file mode 100644 index 0000000000000..69758ef3c3b95 --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaAdapterInstructions.test.ts @@ -0,0 +1,60 @@ +import { assert, describe, it } from "@effect/vitest"; +import { type ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { + CLAUDE_PROVIDER, + makeClaudeQueryOptions, +} from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; +import { buildCodexTurnStartParams } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts"; + +const CLAUDE_TEST_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make(CLAUDE_PROVIDER), + model: "claude-sonnet-4-6", + options: [{ id: "effort", value: "ultrathink" }], +} satisfies ModelSelection; + +describe("agent persona adapter instructions", () => { + it("adds persona instructions to Claude's system prompt without requiring MCP", () => { + const options = makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "native-builder-instructions", + resume: false, + cwd: "/workspace", + agentPersonaInstructions: "Builder instructions", + }); + + assert.isObject(options.systemPrompt); + const systemPrompt = options.systemPrompt as { readonly append?: string }; + assert.include(systemPrompt.append ?? "", "Builder instructions"); + assert.include(systemPrompt.append ?? "", ""); + assert.notInclude(systemPrompt.append ?? "", T3_CODE_ORCHESTRATION_INSTRUCTIONS); + }); + + it.effect("adds persona instructions without requiring the T3 MCP server", () => + Effect.gen(function* () { + const params = yield* buildCodexTurnStartParams({ + nativeThreadId: "native-builder-instructions", + codexInput: [{ type: "text", text: "implement this handoff" }], + runtimePolicy: { + runtimeMode: "auto-accept-edits", + interactionMode: "default", + cwd: "/workspace", + agentPersonaInstructions: "Builder instructions", + }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }, + hasT3Mcp: false, + }); + + assert.equal(params.collaborationMode?.mode, "default"); + assert.equal( + params.collaborationMode?.settings.developer_instructions, + "Builder instructions", + ); + }), + ); +}); diff --git a/apps/server/src/j5/agents/agentPersonaLaunch.integration.test.ts b/apps/server/src/j5/agents/agentPersonaLaunch.integration.test.ts new file mode 100644 index 0000000000000..40aec6fa19690 --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaLaunch.integration.test.ts @@ -0,0 +1,508 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it, vi } from "@effect/vitest"; +import { + CommandId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { stringify as yamlStringify } from "yaml"; + +import * as ServerConfig from "../../config.ts"; +import * as GitWorkflow from "../../git/GitWorkflowService.ts"; +import { CodexProviderCapabilitiesV2 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as CommandReceiptStore from "../../orchestration-v2/CommandReceiptStore.ts"; +import * as EffectOutbox from "../../orchestration-v2/EffectOutbox.ts"; +import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; +import type { ProviderAdapterV2Shape } from "../../orchestration-v2/ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "../../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as ThreadLaunch from "../../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import * as ThreadTitleRegeneration from "../../orchestration-v2/ThreadTitleRegenerationService.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; +import * as ProjectService from "../../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../../project/ProjectSetupScriptRunner.ts"; +import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as TextGeneration from "../../textGeneration/TextGeneration.ts"; +import { SquadronThreadCreationService } from "../a2a/SquadronThreadCreationService.ts"; +import { makeAgentPersonaLibrary } from "./agentPersonaLibrary.ts"; +import { BUILT_IN_AGENT_PERSONAS } from "./agentPersonas.ts"; +import { resolveAgentPersonaRuntime } from "./agentPersonaRuntime.ts"; + +// The harness below mirrors orchestration-v2/ThreadLaunchService.test.ts so persona launch +// cases stay out of the upstream test file (FORK.md). Keep it in sync when that harness moves. +const projectId = ProjectId.make("project:launch-test"); +const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.1-codex", +} as const; +const project = { + id: projectId, + title: "Project", + workspaceRoot: "/repo", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: modelSelection, + defaultThreadEnvMode: null, + scripts: [], + createdAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-06-20T00:00:00.000Z", + deletedAt: null, +} as const; + +const adapter = { + instanceId: modelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("provider execution is disabled in launch tests"), +} as ProviderAdapterV2Shape; + +interface HarnessOptions { + readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"]; + readonly fetchRemote?: GitWorkflow.GitWorkflowService["Service"]["fetchRemote"]; + readonly renameBranch?: GitWorkflow.GitWorkflowService["Service"]["renameBranch"]; + readonly runSetup?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"]; + readonly generateTitle?: TextGeneration.TextGeneration["Service"]["generateThreadTitle"]; + readonly generateBranchName?: TextGeneration.TextGeneration["Service"]["generateBranchName"]; + readonly serverSettings?: Parameters[0]; + readonly providers?: ReadonlyArray; + readonly registerAtDurableLaunch?: SquadronThreadCreationService["Service"]["registerAtDurableLaunch"]; + readonly findRegisteredHome?: SquadronThreadCreationService["Service"]["findRegisteredHome"]; +} + +function makeHarness(options: HarnessOptions = {}) { + const database = SqlitePersistenceMemory; + const registry = ProviderAdapterRegistry.makeLayer([adapter]); + const orchestrator = makeOrchestratorV2ReplayLayerWithRegistry( + { name: "thread-launch" }, + registry, + { databaseLayer: database, runEffectWorker: false }, + ); + const threadManagement = ThreadManagement.layer.pipe(Layer.provide(orchestrator)); + const receipts = CommandReceiptStore.layer.pipe(Layer.provide(database)); + const outbox = EffectOutbox.layer.pipe(Layer.provide(database)); + const createWorktree = vi.fn( + options.createWorktree ?? + ((input) => + Effect.succeed({ + worktree: { path: "/repo-worktrees/feature", refName: input.newRefName, headSha: "abc" }, + } as never)), + ); + const renameBranch = vi.fn( + options.renameBranch ?? ((input) => Effect.succeed({ branch: input.newBranch })), + ); + const runSetup = vi.fn( + options.runSetup ?? (() => Effect.succeed({ status: "no-script" as const })), + ); + const generateBranchName = vi.fn( + options.generateBranchName ?? (() => Effect.succeed({ branch: "generated-branch" })), + ); + const generateThreadTitle = vi.fn( + options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })), + ); + const externalServices = Layer.mergeAll( + Layer.succeed(ProjectService.ProjectService, { + create: () => Effect.die("unused"), + bootstrap: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => Effect.die("unused"), + getById: (id) => Effect.succeed(id === projectId ? Option.some(project) : Option.none()), + getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), + snapshot: Effect.die("unused"), + }), + Layer.mock(GitWorkflow.GitWorkflowService)({ + createWorktree, + renameBranch, + fetchRemote: options.fetchRemote ?? (() => Effect.void), + remoteExists: () => Effect.succeed(true), + remoteBranchExists: () => Effect.succeed(true), + removeWorktree: () => Effect.void, + resolveRemoteTrackingCommit: () => + Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }), + }), + Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { + runForThread: runSetup, + }), + Layer.mock(TextGeneration.TextGeneration)({ + generateThreadTitle, + generateBranchName, + }), + ServerSettings.layerTest(options.serverSettings), + makeProviderRegistryLayer(options.providers), + ); + const launch = ThreadLaunch.layer.pipe( + Layer.provide( + Layer.mergeAll( + externalServices, + threadManagement, + receipts, + IdAllocator.layer, + Layer.mock(SquadronThreadCreationService)({ + registerAtDurableLaunch: + options.registerAtDurableLaunch ?? + (() => + Effect.succeed({ + squadronId: "squadron:launch-test" as never, + participantId: "agent:launch-test" as never, + })), + findRegisteredHome: options.findRegisteredHome ?? (() => Effect.succeed(null)), + }), + ), + ), + ); + const projectedProjects = Layer.mock(ProjectionProjectRepository)({ + getById: ({ projectId: requestedProjectId }) => + Effect.succeed( + requestedProjectId === projectId + ? Option.some({ + projectId, + title: project.title, + workspaceRoot: project.workspaceRoot, + defaultModelSelection: project.defaultModelSelection, + defaultThreadEnvMode: null, + autoPull: false, + scripts: project.scripts, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + deletedAt: project.deletedAt, + }) + : Option.none(), + ), + }); + const titleRegeneration = ThreadTitleRegeneration.layer.pipe( + Layer.provide(Layer.mergeAll(threadManagement, projectedProjects, externalServices)), + ); + return { + layer: Layer.mergeAll(launch, threadManagement, titleRegeneration, outbox, database), + createWorktree, + renameBranch, + generateBranchName, + generateThreadTitle, + runSetup, + }; +} + +function launchInput(input: { + readonly command: string; + readonly thread: string; + readonly message?: string; + readonly workspace?: ThreadLaunch.ThreadLaunchWorkspaceStrategy; + readonly squadronId?: string | null; +}) { + return { + commandId: CommandId.make(input.command), + ...(input.squadronId === null + ? {} + : { squadronId: input.squadronId ?? "squadron:launch-test" }), + threadId: ThreadId.make(input.thread), + projectId, + title: "New thread", + modelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + workspaceStrategy: input.workspace ?? { type: "root" as const }, + ...(input.message === undefined + ? {} + : { + initialMessage: { + messageId: MessageId.make(`${input.message}:id`), + text: input.message, + attachments: [], + }, + }), + createdBy: "user" as const, + creationSource: "web" as const, + }; +} + +it.effect("launches one persona directly without requiring workflow sequencing", () => + Effect.gen(function* () { + const harness = makeHarness({ + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-02T00:00:00.000Z", + availability: "available", + models: [ + { + slug: "gpt-5.6-terra", + name: "GPT-5.6 Terra", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + ], + }, + }, + ], + slashCommands: [], + skills: [], + }, + ], + }); + + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch({ + ...launchInput({ + command: "command:launch:agent-persona", + thread: "thread:launch:agent-persona", + }), + agentPersona: { personaId: "scout" }, + }); + + const expectedAssignment = { + personaId: "scout", + definitionVersion: 1, + authorityPolicy: "read-only", + resolvedRoute: "primary", + resolvedDriver: ProviderDriverKind.make("codex"), + resolvedModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-terra", + options: [{ id: "reasoningEffort", value: "high" }], + }, + } as const; + assert.deepEqual(launched.projection.thread.agentPersonaAssignment, expectedAssignment); + assert.deepEqual( + launched.projection.thread.modelSelection, + expectedAssignment.resolvedModelSelection, + ); + assert.deepEqual( + (yield* threads.getThreadShell(launched.threadId))?.agentPersonaAssignment, + expectedAssignment, + ); + + const switchError = yield* threads + .dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("command:launch:agent-persona:switch"), + threadId: launched.threadId, + modelSelection, + }) + .pipe(Effect.flip); + assert.equal( + switchError.cause, + `Agent persona thread ${launched.threadId} has an immutable model route.`, + ); + + const messageOverrideError = yield* threads + .dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:launch:agent-persona:model-override"), + threadId: launched.threadId, + messageId: MessageId.make("message:launch:agent-persona:model-override"), + text: "Use another model", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }) + .pipe(Effect.flip); + assert.equal( + messageOverrideError.cause, + `Agent persona thread ${launched.threadId} has an immutable model route.`, + ); + + const publisherError = yield* launches + .launch({ + ...launchInput({ + command: "command:launch:publisher-blocked", + thread: "thread:launch:publisher-blocked", + }), + agentPersona: { personaId: "publisher" }, + }) + .pipe(Effect.flip); + assert.equal( + publisherError.message, + "Agent persona publisher is blocked because neither route can enforce its authority policy.", + ); + + const invalidAuthorityError = yield* launches + .launch({ + ...launchInput({ + command: "command:launch:persona-invalid-authority", + thread: "thread:launch:persona-invalid-authority", + }), + agentPersona: { personaId: "scout", authorityPolicy: "publish-only" }, + }) + .pipe(Effect.flip); + assert.equal( + invalidAuthorityError.message, + "Authority policy publish-only is not allowed for scout.", + ); + }).pipe(Effect.provide(harness.layer)); + }), +); +it.effect("blocks a direct persona launch when both declared model routes are unavailable", () => + Effect.gen(function* () { + const harness = makeHarness({ providers: [] }); + + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const error = yield* launches + .launch({ + ...launchInput({ + command: "command:launch:blocked-agent-persona", + thread: "thread:launch:blocked-agent-persona", + }), + agentPersona: { personaId: "scout" }, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ThreadLaunch.ThreadLaunchError); + assert.equal(error.operation, "resolve-agent-persona"); + assert.equal( + error.message, + "Agent persona scout is blocked because its primary and fallback models are unavailable.", + ); + }).pipe(Effect.provide(harness.layer)); + }), +); + +const yamlPersonaFixture = (value: unknown) => yamlStringify(value); + +it.effect( + "launches an imported persona and replays its receipt after the source is removed", + () => { + const definition = { + ...BUILT_IN_AGENT_PERSONAS.scout, + id: "team-researcher", + displayName: "Team Researcher", + version: 2, + }; + const harness = makeHarness({ + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-08T00:00:00.000Z", + availability: "available", + slashCommands: [], + skills: [], + models: [ + { + slug: definition.modelRoute[0].model, + name: "Research model", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [{ id: "high", label: "High" }], + }, + ], + }, + }, + ], + }, + ], + }); + const environment = ServerConfig.layerTest("/repo", { prefix: "j5-persona-launch-" }); + return Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const folder = path.join(config.stateDir, "personas"); + yield* fs.makeDirectory(folder, { recursive: true }); + yield* fs.writeFileString(path.join(folder, "team.yaml"), yamlPersonaFixture(definition)); + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const input = { + ...launchInput({ command: "command:imported-persona", thread: "thread:imported-persona" }), + agentPersona: { personaId: definition.id }, + }; + const launched = yield* launches.launch(input); + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.equal(projection.thread.agentPersonaAssignment?.displayName, definition.displayName); + assert.match( + projection.thread.agentPersonaAssignment?.definitionDigest ?? "", + /^[a-f0-9]{64}$/, + ); + const library = yield* makeAgentPersonaLibrary; + yield* library.importFiles({ + files: [{ name: "agent.yaml", content: yamlPersonaFixture(definition) }], + replaceExisting: true, + }); + yield* library.setImportedEnabled(definition.id, false); + const disabledError = yield* launches + .launch({ + ...input, + commandId: CommandId.make("command:imported-persona:disabled"), + threadId: ThreadId.make("thread:imported-persona:disabled"), + }) + .pipe(Effect.flip); + assert.equal(disabledError.operation, "resolve-agent-persona"); + assert.include(disabledError.message, "disabled"); + assert.equal((yield* launches.launch(input)).threadId, launched.threadId); + yield* library.setImportedEnabled(definition.id, true); + const reenabled = yield* launches.launch({ + ...input, + commandId: CommandId.make("command:imported-persona:enabled"), + threadId: ThreadId.make("thread:imported-persona:enabled"), + }); + assert.equal( + reenabled.projection.thread.agentPersonaAssignment?.definitionDigest, + projection.thread.agentPersonaAssignment?.definitionDigest, + ); + yield* library.removeImported(definition.id); + yield* fs.remove(folder, { recursive: true }); + const replay = yield* launches.launch(input); + assert.equal(replay.threadId, launched.threadId); + const policy = yield* resolveAgentPersonaRuntime(projection.thread, library); + assert.include( + "agentPersonaInstructions" in policy ? policy.agentPersonaInstructions : "", + definition.instructions, + ); + const error = yield* launches + .launch({ + ...input, + commandId: CommandId.make("command:imported-persona:missing"), + threadId: ThreadId.make("thread:imported-persona:missing"), + }) + .pipe(Effect.flip); + assert.equal(error.operation, "resolve-agent-persona"); + assert.include(error.message, "Unknown agent persona"); + }).pipe( + Effect.provide( + harness.layer.pipe(Layer.provideMerge(environment), Layer.provideMerge(NodeServices.layer)), + ), + Effect.scoped, + ); + }, +); diff --git a/apps/server/src/j5/agents/agentPersonaProjection.test.ts b/apps/server/src/j5/agents/agentPersonaProjection.test.ts new file mode 100644 index 0000000000000..9b4ff79b957b1 --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaProjection.test.ts @@ -0,0 +1,125 @@ +import { assert, it } from "@effect/vitest"; +import { + EventId, + type ModelSelection, + type OrchestrationV2AppThread, + type OrchestrationV2DomainEvent, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { EventSinkV2, layer as eventSinkLayer } from "../../orchestration-v2/EventSink.ts"; +import { layer as eventStoreLayer } from "../../orchestration-v2/EventStore.ts"; +import { + ProjectionMaintenanceV2, + layer as projectionMaintenanceLayer, +} from "../../orchestration-v2/ProjectionMaintenance.ts"; +import { + ProjectionStoreV2, + layer as projectionStoreLayer, +} from "../../orchestration-v2/ProjectionStore.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; + +// Mirrors the store composition in orchestration-v2/FoundationPersistence.test.ts (FORK.md). +const databaseLayer = SqlitePersistenceMemory; +const eventStoreProvided = eventStoreLayer.pipe(Layer.provideMerge(databaseLayer)); +const projectionStoreProvided = projectionStoreLayer.pipe(Layer.provideMerge(databaseLayer)); +const storesProvided = Layer.mergeAll(databaseLayer, eventStoreProvided, projectionStoreProvided); +const eventSinkProvided = eventSinkLayer.pipe(Layer.provide(storesProvided)); +const projectionMaintenanceProvided = projectionMaintenanceLayer.pipe( + Layer.provide(storesProvided), +); +const TestLayer = Layer.mergeAll(storesProvided, eventSinkProvided, projectionMaintenanceProvided); + +const providerInstanceId = ProviderInstanceId.make("codex"); +const providerDriver = ProviderDriverKind.make("codex"); +const modelSelection = { + instanceId: providerInstanceId, + model: "gpt-5.4", +} satisfies ModelSelection; + +function makeThread(threadId: ThreadId, now: DateTime.Utc): OrchestrationV2AppThread { + return { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make(`project:${threadId}`), + title: `Thread ${threadId}`, + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: threadId, + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; +} + +function threadCreatedEvent(input: { + readonly id: string; + readonly thread: OrchestrationV2AppThread; + readonly now: DateTime.Utc; +}): OrchestrationV2DomainEvent { + return { + id: EventId.make(input.id), + type: "thread.created", + threadId: input.thread.id, + providerInstanceId, + occurredAt: input.now, + payload: input.thread, + }; +} + +it.layer(TestLayer)("agent persona projection persistence", (it) => { + it.effect("preserves the exact historical agent assignment through projection rebuilds", () => + Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const projectionStore = yield* ProjectionStoreV2; + const maintenance = yield* ProjectionMaintenanceV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:foundation-agent-persona"); + const assignment = { + personaId: "team-builder", + definitionVersion: 3, + definitionDigest: "a".repeat(64), + displayName: "Original Team Builder", + authorityPolicy: "workspace-write", + resolvedRoute: "primary", + resolvedDriver: providerDriver, + resolvedModelSelection: modelSelection, + } as const; + const thread = { ...makeThread(threadId, now), agentPersonaAssignment: assignment }; + + yield* eventSink.write({ + events: [threadCreatedEvent({ id: "event:foundation-agent-persona", thread, now })], + }); + assert.deepEqual( + (yield* projectionStore.getThreadProjection(threadId)).thread.agentPersonaAssignment, + assignment, + ); + assert.isTrue((yield* maintenance.rebuild).valid); + assert.deepEqual( + (yield* projectionStore.getThreadProjection(threadId)).thread.agentPersonaAssignment, + assignment, + ); + }), + ); +}); diff --git a/apps/server/src/j5/agents/agentPersonaRpc.test.ts b/apps/server/src/j5/agents/agentPersonaRpc.test.ts new file mode 100644 index 0000000000000..b5a4a035964b0 --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaRpc.test.ts @@ -0,0 +1,34 @@ +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + J5_AGENT_PERSONA_WS_METHODS, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { requiredScopeForRpcMethod } from "../../auth/RpcAuthorization.ts"; + +describe("agent persona RPC scopes", () => { + it("requires operate access to import, edit, toggle, or remove persona definitions", () => { + expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.editImportedAgentPersona)).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.removeAgentPersona)).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.removeSourceAgentPersona)).toBe( + AuthOrchestrationOperateScope, + ); + expect( + requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.setImportedAgentPersonaEnabled), + ).toBe(AuthOrchestrationOperateScope); + expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.getAgentPersonaCatalog)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.importAgentPersonas)).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForRpcMethod(J5_AGENT_PERSONA_WS_METHODS.removeImportedAgentPersona)).toBe( + AuthOrchestrationOperateScope, + ); + }); +}); diff --git a/apps/server/src/j5/agents/agentPersonaRuntimePolicy.test.ts b/apps/server/src/j5/agents/agentPersonaRuntimePolicy.test.ts new file mode 100644 index 0000000000000..a30aa8d2b328a --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaRuntimePolicy.test.ts @@ -0,0 +1,166 @@ +import { assert, it } from "@effect/vitest"; +import { + type ModelSelection, + type OrchestrationV2AppThread, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { + layerFromProjectRepository, + RuntimePolicyV2, +} from "../../orchestration-v2/RuntimePolicy.ts"; +import * as ProjectionProjects from "../../persistence/Services/ProjectionProjects.ts"; +import { BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1 } from "./agentPersonaPrompts.ts"; + +// Mirrors the fixtures in orchestration-v2/RuntimePolicy.test.ts (FORK.md). +const projectId = ProjectId.make("project:runtime-policy"); +const providerInstanceId = ProviderInstanceId.make("codex"); +const modelSelection = { + instanceId: providerInstanceId, + model: "gpt-5.5", +} satisfies ModelSelection; + +function makeThread(input: { + readonly now: DateTime.Utc; + readonly worktreePath: string | null; +}): OrchestrationV2AppThread { + const threadId = ThreadId.make("thread:runtime-policy"); + return { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: "Runtime policy", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: input.worktreePath, + activeProviderThreadId: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: threadId, + }, + forkedFrom: null, + createdAt: input.now, + updatedAt: input.now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; +} + +const TestLayer = layerFromProjectRepository.pipe( + Layer.provide( + Layer.mock(ProjectionProjects.ProjectionProjectRepository)({ + getById: () => + Effect.succeed( + Option.some({ + projectId, + title: "Project", + workspaceRoot: "/project-root", + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + scripts: [], + createdAt: "2026-06-21T00:00:00.000Z", + updatedAt: "2026-06-21T00:00:00.000Z", + deletedAt: null, + }), + ), + }), + ), +); + +it.layer(TestLayer)("agent persona runtime policy", (it) => { + it.effect("lets durable persona authority override the thread runtime mode", () => + Effect.gen(function* () { + const policy = yield* RuntimePolicyV2; + const now = yield* DateTime.now; + const thread = { + ...makeThread({ now, worktreePath: "/project-worktree" }), + agentPersonaAssignment: { + personaId: "critic", + definitionVersion: 1, + authorityPolicy: "critic-review", + resolvedRoute: "primary", + resolvedDriver: ProviderDriverKind.make("codex"), + resolvedModelSelection: modelSelection, + }, + } satisfies OrchestrationV2AppThread; + const resolved = yield* policy.resolve({ thread, modelSelection }); + + assert.deepEqual(resolved, { + runtimeMode: "approval-required", + interactionMode: "default", + cwd: "/project-worktree", + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }); + }), + ); + + it.effect("degrades an unsafe historical persona assignment to read-only", () => + Effect.gen(function* () { + const policy = yield* RuntimePolicyV2; + const now = yield* DateTime.now; + const thread = { + ...makeThread({ now, worktreePath: "/project-worktree" }), + agentPersonaAssignment: { + personaId: "publisher", + definitionVersion: 1, + authorityPolicy: "publish-only", + resolvedRoute: "primary", + resolvedDriver: ProviderDriverKind.make("codex"), + resolvedModelSelection: modelSelection, + }, + } satisfies OrchestrationV2AppThread; + const resolved = yield* policy.resolve({ thread, modelSelection }); + + assert.equal(resolved.runtimeMode, "approval-required"); + assert.equal(resolved.approvalPolicy, "never"); + assert.deepEqual(resolved.sandboxPolicy, { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }); + }), + ); + + it.effect("adds the versioned Builder instructions to its runtime policy", () => + Effect.gen(function* () { + const policy = yield* RuntimePolicyV2; + const now = yield* DateTime.now; + const thread = { + ...makeThread({ now, worktreePath: "/project-worktree" }), + agentPersonaAssignment: { + personaId: "builder", + definitionVersion: 1, + authorityPolicy: "workspace-write", + resolvedRoute: "primary", + resolvedDriver: ProviderDriverKind.make("codex"), + resolvedModelSelection: modelSelection, + }, + } satisfies OrchestrationV2AppThread; + + const resolved = yield* policy.resolve({ thread, modelSelection }); + + assert.equal(resolved.agentPersonaInstructions, BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 230d940ead29e..8a40bfcaf420e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -653,22 +653,6 @@ describe("ClaudeAdapterV2 native protocol logging", () => { } }); - it("adds persona instructions to Claude's system prompt without requiring MCP", () => { - const options = makeClaudeQueryOptions({ - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - nativeThreadId: "native-builder-instructions", - resume: false, - cwd: "/workspace", - agentPersonaInstructions: "Builder instructions", - }); - - assert.isObject(options.systemPrompt); - const systemPrompt = options.systemPrompt as { readonly append?: string }; - assert.include(systemPrompt.append ?? "", "Builder instructions"); - assert.include(systemPrompt.append ?? "", ""); - assert.notInclude(systemPrompt.append ?? "", T3_CODE_ORCHESTRATION_INSTRUCTIONS); - }); - it.effect("writes Claude Agent SDK protocol frames to the native provider log", () => Effect.gen(function* () { const writes: Array<{ diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index b8dbb740c469d..d89815c84a1ea 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -470,32 +470,6 @@ describe("CodexAdapterV2 runtime policy", () => { }), ); - it.effect("adds persona instructions without requiring the T3 MCP server", () => - Effect.gen(function* () { - const params = yield* buildCodexTurnStartParams({ - nativeThreadId: "native-builder-instructions", - codexInput: [{ type: "text", text: "implement this handoff" }], - runtimePolicy: { - runtimeMode: "auto-accept-edits", - interactionMode: "default", - cwd: "/workspace", - agentPersonaInstructions: "Builder instructions", - }, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.6-sol", - }, - hasT3Mcp: false, - }); - - assert.equal(params.collaborationMode?.mode, "default"); - assert.equal( - params.collaborationMode?.settings.developer_instructions, - "Builder instructions", - ); - }), - ); - it.effect("adds T3 plan-mode developer instructions when the T3 MCP server is attached", () => Effect.gen(function* () { const params = yield* buildCodexTurnStartParams({ diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index 825c6a528e1f6..584d218a61c57 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -463,40 +463,6 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { ), ); - it.effect("preserves the exact historical agent assignment through projection rebuilds", () => - Effect.gen(function* () { - const eventSink = yield* EventSinkV2; - const projectionStore = yield* ProjectionStoreV2; - const maintenance = yield* ProjectionMaintenanceV2; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread:foundation-agent-persona"); - const assignment = { - personaId: "team-builder", - definitionVersion: 3, - definitionDigest: "a".repeat(64), - displayName: "Original Team Builder", - authorityPolicy: "workspace-write", - resolvedRoute: "primary", - resolvedDriver: providerDriver, - resolvedModelSelection: modelSelection, - } as const; - const thread = { ...makeThread(threadId, now), agentPersonaAssignment: assignment }; - - yield* eventSink.write({ - events: [threadCreatedEvent({ id: "event:foundation-agent-persona", thread, now })], - }); - assert.deepEqual( - (yield* projectionStore.getThreadProjection(threadId)).thread.agentPersonaAssignment, - assignment, - ); - assert.isTrue((yield* maintenance.rebuild).valid); - assert.deepEqual( - (yield* projectionStore.getThreadProjection(threadId)).thread.agentPersonaAssignment, - assignment, - ); - }), - ); - it.effect("paginates catch-up beyond the event-store read limit", () => Effect.gen(function* () { const eventSink = yield* EventSinkV2; diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index 8b83cb1a7b331..f08d5525cb7f8 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -3,7 +3,6 @@ import { type ModelSelection, type OrchestrationV2AppThread, ProjectId, - ProviderDriverKind, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; @@ -13,7 +12,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts"; -import { BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1 } from "../j5/agents/agentPersonaPrompts.ts"; import { layerFromProjectRepository, RuntimePolicyV2 } from "./RuntimePolicy.ts"; const projectId = ProjectId.make("project:runtime-policy"); @@ -89,7 +87,6 @@ it.layer(TestLayer)("RuntimePolicyV2", (it) => { modelSelection, }); assert.equal(resolved.cwd, "/project-root"); - assert.equal(resolved.runtimeMode, "full-access"); }), ); @@ -104,84 +101,4 @@ it.layer(TestLayer)("RuntimePolicyV2", (it) => { assert.equal(resolved.cwd, "/project-worktree"); }), ); - - it.effect("lets durable persona authority override the thread runtime mode", () => - Effect.gen(function* () { - const policy = yield* RuntimePolicyV2; - const now = yield* DateTime.now; - const thread = { - ...makeThread({ now, worktreePath: "/project-worktree" }), - agentPersonaAssignment: { - personaId: "critic", - definitionVersion: 1, - authorityPolicy: "critic-review", - resolvedRoute: "primary", - resolvedDriver: ProviderDriverKind.make("codex"), - resolvedModelSelection: modelSelection, - }, - } satisfies OrchestrationV2AppThread; - const resolved = yield* policy.resolve({ thread, modelSelection }); - - assert.deepEqual(resolved, { - runtimeMode: "approval-required", - interactionMode: "default", - cwd: "/project-worktree", - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }); - }), - ); - - it.effect("degrades an unsafe historical persona assignment to read-only", () => - Effect.gen(function* () { - const policy = yield* RuntimePolicyV2; - const now = yield* DateTime.now; - const thread = { - ...makeThread({ now, worktreePath: "/project-worktree" }), - agentPersonaAssignment: { - personaId: "publisher", - definitionVersion: 1, - authorityPolicy: "publish-only", - resolvedRoute: "primary", - resolvedDriver: ProviderDriverKind.make("codex"), - resolvedModelSelection: modelSelection, - }, - } satisfies OrchestrationV2AppThread; - const resolved = yield* policy.resolve({ thread, modelSelection }); - - assert.equal(resolved.runtimeMode, "approval-required"); - assert.equal(resolved.approvalPolicy, "never"); - assert.deepEqual(resolved.sandboxPolicy, { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }); - }), - ); - - it.effect("adds the versioned Builder instructions to its runtime policy", () => - Effect.gen(function* () { - const policy = yield* RuntimePolicyV2; - const now = yield* DateTime.now; - const thread = { - ...makeThread({ now, worktreePath: "/project-worktree" }), - agentPersonaAssignment: { - personaId: "builder", - definitionVersion: 1, - authorityPolicy: "workspace-write", - resolvedRoute: "primary", - resolvedDriver: ProviderDriverKind.make("codex"), - resolvedModelSelection: modelSelection, - }, - } satisfies OrchestrationV2AppThread; - - const resolved = yield* policy.resolve({ thread, modelSelection }); - - assert.equal(resolved.agentPersonaInstructions, BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1); - }), - ); }); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 4683fec8ee40f..baf0ef7004ea0 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1,11 +1,3 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as ServerConfig from "../config.ts"; -import { BUILT_IN_AGENT_PERSONAS } from "../j5/agents/agentPersonas.ts"; -import { makeAgentPersonaLibrary } from "../j5/agents/agentPersonaLibrary.ts"; -import { resolveAgentPersonaRuntime } from "../j5/agents/agentPersonaRuntime.ts"; - import { assert, it, vi } from "@effect/vitest"; import { CommandId, @@ -22,7 +14,6 @@ import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import { stringify as yamlStringify } from "yaml"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -380,166 +371,7 @@ it.effect("keeps a proposed-plan child of a no-home legacy parent native", () => assert.equal(launched.threadId, ThreadId.make("thread:launch:legacy-plan")); }).pipe(Effect.provide(harness.layer)); }); -it.effect("launches one persona directly without requiring workflow sequencing", () => - Effect.gen(function* () { - const harness = makeHarness({ - providers: [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: null, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-09-02T00:00:00.000Z", - availability: "available", - models: [ - { - slug: "gpt-5.6-terra", - name: "GPT-5.6 Terra", - isCustom: false, - capabilities: { - optionDescriptors: [ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - options: [ - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - ], - }, - ], - }, - }, - ], - slashCommands: [], - skills: [], - }, - ], - }); - - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const launched = yield* launches.launch({ - ...launchInput({ - command: "command:launch:agent-persona", - thread: "thread:launch:agent-persona", - }), - agentPersona: { personaId: "scout" }, - }); - - const expectedAssignment = { - personaId: "scout", - definitionVersion: 1, - authorityPolicy: "read-only", - resolvedRoute: "primary", - resolvedDriver: ProviderDriverKind.make("codex"), - resolvedModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.6-terra", - options: [{ id: "reasoningEffort", value: "high" }], - }, - } as const; - assert.deepEqual(launched.projection.thread.agentPersonaAssignment, expectedAssignment); - assert.deepEqual( - launched.projection.thread.modelSelection, - expectedAssignment.resolvedModelSelection, - ); - assert.deepEqual( - (yield* threads.getThreadShell(launched.threadId))?.agentPersonaAssignment, - expectedAssignment, - ); - - const switchError = yield* threads - .dispatch({ - type: "thread.model-selection.set", - commandId: CommandId.make("command:launch:agent-persona:switch"), - threadId: launched.threadId, - modelSelection, - }) - .pipe(Effect.flip); - assert.equal( - switchError.cause, - `Agent persona thread ${launched.threadId} has an immutable model route.`, - ); - - const messageOverrideError = yield* threads - .dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:launch:agent-persona:model-override"), - threadId: launched.threadId, - messageId: MessageId.make("message:launch:agent-persona:model-override"), - text: "Use another model", - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - }) - .pipe(Effect.flip); - assert.equal( - messageOverrideError.cause, - `Agent persona thread ${launched.threadId} has an immutable model route.`, - ); - - const publisherError = yield* launches - .launch({ - ...launchInput({ - command: "command:launch:publisher-blocked", - thread: "thread:launch:publisher-blocked", - }), - agentPersona: { personaId: "publisher" }, - }) - .pipe(Effect.flip); - assert.equal( - publisherError.message, - "Agent persona publisher is blocked because neither route can enforce its authority policy.", - ); - - const invalidAuthorityError = yield* launches - .launch({ - ...launchInput({ - command: "command:launch:persona-invalid-authority", - thread: "thread:launch:persona-invalid-authority", - }), - agentPersona: { personaId: "scout", authorityPolicy: "publish-only" }, - }) - .pipe(Effect.flip); - assert.equal( - invalidAuthorityError.message, - "Authority policy publish-only is not allowed for scout.", - ); - }).pipe(Effect.provide(harness.layer)); - }), -); -it.effect("blocks a direct persona launch when both declared model routes are unavailable", () => - Effect.gen(function* () { - const harness = makeHarness({ providers: [] }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const error = yield* launches - .launch({ - ...launchInput({ - command: "command:launch:blocked-agent-persona", - thread: "thread:launch:blocked-agent-persona", - }), - agentPersona: { personaId: "scout" }, - }) - .pipe(Effect.flip); - - assert.instanceOf(error, ThreadLaunch.ThreadLaunchError); - assert.equal(error.operation, "resolve-agent-persona"); - assert.equal( - error.message, - "Agent persona scout is blocked because its primary and fallback models are unavailable.", - ); - }).pipe(Effect.provide(harness.layer)); - }), -); it.effect("returns a visible preparing message while provisioning is still blocked", () => Effect.gen(function* () { const worktreeEntered = yield* Deferred.make(); @@ -1608,122 +1440,3 @@ it.effect("does not depend on the legacy launch workflow table", () => { assert.equal(launched.projection.messages[0]?.text, "No private workflow state"); }).pipe(Effect.provide(harness.layer)); }); - -const yamlPersonaFixture = (value: unknown) => yamlStringify(value); - -it.effect( - "launches an imported persona and replays its receipt after the source is removed", - () => { - const definition = { - ...BUILT_IN_AGENT_PERSONAS.scout, - id: "team-researcher", - displayName: "Team Researcher", - version: 2, - }; - const harness = makeHarness({ - providers: [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: null, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-09-08T00:00:00.000Z", - availability: "available", - slashCommands: [], - skills: [], - models: [ - { - slug: definition.modelRoute[0].model, - name: "Research model", - isCustom: false, - capabilities: { - optionDescriptors: [ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - options: [{ id: "high", label: "High" }], - }, - ], - }, - }, - ], - }, - ], - }); - const environment = ServerConfig.layerTest("/repo", { prefix: "j5-persona-launch-" }); - return Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const folder = path.join(config.stateDir, "personas"); - yield* fs.makeDirectory(folder, { recursive: true }); - yield* fs.writeFileString(path.join(folder, "team.yaml"), yamlPersonaFixture(definition)); - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const input = { - ...launchInput({ command: "command:imported-persona", thread: "thread:imported-persona" }), - agentPersona: { personaId: definition.id }, - }; - const launched = yield* launches.launch(input); - const projection = yield* threads.getThreadProjection(launched.threadId); - assert.equal(projection.thread.agentPersonaAssignment?.displayName, definition.displayName); - assert.match( - projection.thread.agentPersonaAssignment?.definitionDigest ?? "", - /^[a-f0-9]{64}$/, - ); - const library = yield* makeAgentPersonaLibrary; - yield* library.importFiles({ - files: [{ name: "agent.yaml", content: yamlPersonaFixture(definition) }], - replaceExisting: true, - }); - yield* library.setImportedEnabled(definition.id, false); - const disabledError = yield* launches - .launch({ - ...input, - commandId: CommandId.make("command:imported-persona:disabled"), - threadId: ThreadId.make("thread:imported-persona:disabled"), - }) - .pipe(Effect.flip); - assert.equal(disabledError.operation, "resolve-agent-persona"); - assert.include(disabledError.message, "disabled"); - assert.equal((yield* launches.launch(input)).threadId, launched.threadId); - yield* library.setImportedEnabled(definition.id, true); - const reenabled = yield* launches.launch({ - ...input, - commandId: CommandId.make("command:imported-persona:enabled"), - threadId: ThreadId.make("thread:imported-persona:enabled"), - }); - assert.equal( - reenabled.projection.thread.agentPersonaAssignment?.definitionDigest, - projection.thread.agentPersonaAssignment?.definitionDigest, - ); - yield* library.removeImported(definition.id); - yield* fs.remove(folder, { recursive: true }); - const replay = yield* launches.launch(input); - assert.equal(replay.threadId, launched.threadId); - const policy = yield* resolveAgentPersonaRuntime(projection.thread, library); - assert.include( - "agentPersonaInstructions" in policy ? policy.agentPersonaInstructions : "", - definition.instructions, - ); - const error = yield* launches - .launch({ - ...input, - commandId: CommandId.make("command:imported-persona:missing"), - threadId: ThreadId.make("thread:imported-persona:missing"), - }) - .pipe(Effect.flip); - assert.equal(error.operation, "resolve-agent-persona"); - assert.include(error.message, "Unknown agent persona"); - }).pipe( - Effect.provide( - harness.layer.pipe(Layer.provideMerge(environment), Layer.provideMerge(NodeServices.layer)), - ), - Effect.scoped, - ); - }, -); diff --git a/packages/contracts/src/j5/agentPersona.test.ts b/packages/contracts/src/j5/agentPersona.test.ts new file mode 100644 index 0000000000000..f437c508d9d9a --- /dev/null +++ b/packages/contracts/src/j5/agentPersona.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { OrchestrationV2Command, OrchestrationV2PublicCommand } from "../orchestrationV2.ts"; +import { AgentPersonaId, OrchestrationV2AgentPersonaAssignment } from "./agentPersona.ts"; + +const decodeOrchestrationV2Command = Schema.decodeUnknownSync(OrchestrationV2Command); +const decodeOrchestrationV2PublicCommand = Schema.decodeUnknownSync(OrchestrationV2PublicCommand); +const decodePersonaId = Schema.decodeUnknownSync(AgentPersonaId); +const decodePersonaAssignment = Schema.decodeUnknownSync(OrchestrationV2AgentPersonaAssignment); + +describe("agent persona contracts", () => { + it("rejects server-owned persona assignments on the public command boundary", () => { + const command = { + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: "command-public-persona", + threadId: "thread-public-persona", + projectId: "project-public-persona", + title: "Public persona", + modelSelection: { instanceId: "codex", model: "gpt-5.6-terra" }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + agentPersonaAssignment: { + personaId: "scout", + definitionVersion: 1, + authorityPolicy: "publish-only", + resolvedRoute: "primary", + resolvedDriver: "codex", + resolvedModelSelection: { instanceId: "codex", model: "gpt-5.6-terra" }, + }, + }; + + expect(() => decodeOrchestrationV2PublicCommand(command)).toThrow( + "Resolved agent persona assignments are server-owned.", + ); + expect(decodeOrchestrationV2Command(command).type).toBe("thread.create"); + }); + + it("accepts custom persona ids and validates snapshot references without requiring them on legacy assignments", () => { + expect(decodePersonaId("team-researcher")).toBe("team-researcher"); + expect(() => decodePersonaId("../escape")).toThrow(); + const assignment = { + personaId: "team-researcher", + definitionVersion: 4, + displayName: "Team Researcher", + definitionDigest: "a".repeat(64), + authorityPolicy: "read-only", + resolvedRoute: "primary", + resolvedDriver: "codex", + resolvedModelSelection: { instanceId: "codex", model: "research-model" }, + }; + expect(decodePersonaAssignment(assignment)).toEqual(assignment); + expect(() => + decodePersonaAssignment({ + ...assignment, + definitionDigest: "../escape", + }), + ).toThrow(); + const { definitionDigest: _digest, displayName: _name, ...legacy } = assignment; + expect(decodePersonaAssignment(legacy).definitionDigest).toBeUndefined(); + }); +}); diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 8234047b8c20b..21ec7e75e214f 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -1,4 +1,3 @@ -import { AgentPersonaId, OrchestrationV2AgentPersonaAssignment } from "./j5/agentPersona.ts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Schema from "effect/Schema"; @@ -27,7 +26,6 @@ import { OrchestrationV2DomainEvent, OrchestrationV2ProviderThread, OrchestrationV2ProviderThreadJson, - OrchestrationV2PublicCommand, OrchestrationV2ShellSnapshot, OrchestrationV2Subagent, OrchestrationV2ThreadProjection, @@ -45,7 +43,6 @@ const LegacyShellStreamItem = Schema.Union([ ]); const decodeLegacyShellStreamItem = Schema.decodeUnknownSync(LegacyShellStreamItem); const decodeOrchestrationV2Command = Schema.decodeUnknownSync(OrchestrationV2Command); -const decodeOrchestrationV2PublicCommand = Schema.decodeUnknownSync(OrchestrationV2PublicCommand); const decodeOrchestrationV2TurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem); const decodeOrchestrationV2CheckpointScope = Schema.decodeUnknownSync( OrchestrationV2CheckpointScope, @@ -64,36 +61,6 @@ const decodeOrchestrationV2ProviderThread = Schema.decodeUnknownSync(Orchestrati const decodeOrchestrationV2ThreadShell = Schema.decodeUnknownSync(OrchestrationV2ThreadShell); describe("orchestration V2 contracts", () => { - it("rejects server-owned persona assignments on the public command boundary", () => { - const command = { - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: "command-public-persona", - threadId: "thread-public-persona", - projectId: "project-public-persona", - title: "Public persona", - modelSelection: { instanceId: "codex", model: "gpt-5.6-terra" }, - runtimeMode: "approval-required", - interactionMode: "default", - branch: null, - worktreePath: null, - agentPersonaAssignment: { - personaId: "scout", - definitionVersion: 1, - authorityPolicy: "publish-only", - resolvedRoute: "primary", - resolvedDriver: "codex", - resolvedModelSelection: { instanceId: "codex", model: "gpt-5.6-terra" }, - }, - }; - - expect(() => decodeOrchestrationV2PublicCommand(command)).toThrow( - "Resolved agent persona assignments are server-owned.", - ); - expect(decodeOrchestrationV2Command(command).type).toBe("thread.create"); - }); - it("lets legacy snapshot decoders ignore enrichment metadata", () => { const decoded = decodeLegacyShellStreamItem({ kind: "snapshot", @@ -803,30 +770,3 @@ describe("orchestration V2 contracts", () => { expect(shell.pendingBackgroundTasks).toEqual([]); }); }); - -const decodePersonaId = Schema.decodeUnknownSync(AgentPersonaId); -const decodePersonaAssignment = Schema.decodeUnknownSync(OrchestrationV2AgentPersonaAssignment); - -it("accepts custom persona ids and validates snapshot references without requiring them on legacy assignments", () => { - expect(decodePersonaId("team-researcher")).toBe("team-researcher"); - expect(() => decodePersonaId("../escape")).toThrow(); - const assignment = { - personaId: "team-researcher", - definitionVersion: 4, - displayName: "Team Researcher", - definitionDigest: "a".repeat(64), - authorityPolicy: "read-only", - resolvedRoute: "primary", - resolvedDriver: "codex", - resolvedModelSelection: { instanceId: "codex", model: "research-model" }, - }; - expect(decodePersonaAssignment(assignment)).toEqual(assignment); - expect(() => - decodePersonaAssignment({ - ...assignment, - definitionDigest: "../escape", - }), - ).toThrow(); - const { definitionDigest: _digest, displayName: _name, ...legacy } = assignment; - expect(decodePersonaAssignment(legacy).definitionDigest).toBeUndefined(); -}); From c3358b4364d6f583d72c4a07482fb04deb39c261 Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Wed, 9 Sep 2026 12:28:23 -0400 Subject: [PATCH 7/7] refactor(agents): build the orchestrator persona guards once The orchestrator constructs one J5 guard set next to its other services, handing over only its two error constructors and the adapter lookup; the create, route-lock, and model-mismatch sites become single calls. Runtime policy uses a J5 resolver that takes its error constructor the same way. Co-Authored-By: Claude Fable 5.1 --- .../j5/agents/agentPersonaOrchestration.ts | 56 ++++++++++++++- .../src/j5/agents/agentPersonaRuntime.ts | 26 ++++++- .../src/orchestration-v2/Orchestrator.ts | 69 ++++++------------- .../src/orchestration-v2/RuntimePolicy.ts | 37 ++++------ 4 files changed, 113 insertions(+), 75 deletions(-) diff --git a/apps/server/src/j5/agents/agentPersonaOrchestration.ts b/apps/server/src/j5/agents/agentPersonaOrchestration.ts index 2bba33eaa24c5..298485f38a81b 100644 --- a/apps/server/src/j5/agents/agentPersonaOrchestration.ts +++ b/apps/server/src/j5/agents/agentPersonaOrchestration.ts @@ -1,4 +1,5 @@ import type { + CommandId, ModelSelection, OrchestrationV2AgentPersonaAssignment, OrchestrationV2AgentPersonaRequest, @@ -14,7 +15,11 @@ import * as Schema from "effect/Schema"; import { validateAgentPersonaAssignment } from "./agentPersonaAssignment.ts"; import { prepareAgentPersonaLaunch } from "./agentPersonaLaunch.ts"; -import { AgentPersonaLibraryError, type createAgentPersonaLibrary } from "./agentPersonaLibrary.ts"; +import { + AgentPersonaLibraryError, + makeAgentPersonaLibrary, + type createAgentPersonaLibrary, +} from "./agentPersonaLibrary.ts"; type Library = ReturnType; type PersonaThread = Pick; @@ -125,3 +130,52 @@ export const durableLaunchModelSelection = ( const created = storedEvents.find((stored) => stored.event.type === "thread.created")?.event; return created?.type === "thread.created" ? created.payload.modelSelection : fallback; }; + +type CommandContext = { readonly commandId: CommandId; readonly type: string }; +type ThreadCreateCommand = Extract; + +/** + * Guards for the upstream orchestrator, built once next to its other services. The upstream + * file supplies only its two error constructors and the adapter lookup; every call site is a + * single `yield*`. Failures use the orchestrator's own error types so dispatch semantics do + * not change. + */ +export const makeAgentPersonaGuards = (deps: { + readonly getDriver: (providerInstanceId: ProviderInstanceId) => Effect.Effect; + readonly adapterError: ( + command: CommandContext, + providerInstanceId: ProviderInstanceId, + cause: E, + ) => A; + readonly dispatchError: (command: CommandContext, cause: unknown) => D; +}) => + Effect.gen(function* () { + const library = yield* makeAgentPersonaLibrary; + const reject = (command: CommandContext, message: string | undefined) => + message === undefined ? Effect.void : Effect.fail(deps.dispatchError(command, message)); + return { + library, + /** `thread.create`: the assignment must be an intact snapshot on the resolved route. */ + threadCreate: (command: ThreadCreateCommand): Effect.Effect => + guardAgentPersonaThreadCreate(command, library, (providerInstanceId) => + deps + .getDriver(providerInstanceId) + .pipe( + Effect.mapError((cause) => deps.adapterError(command, providerInstanceId, cause)), + ), + ).pipe( + Effect.catchIf(isLibraryError, (cause) => + Effect.fail(deps.dispatchError(command, cause)), + ), + ), + /** Thread commands: explicit model or provider changes are rejected on persona threads. */ + routeLocked: (thread: PersonaThread, command: CommandContext): Effect.Effect => + reject(command, agentPersonaRouteLockedError(thread, command.type)), + /** Message dispatch: a different model selection than the resolved route is rejected. */ + modelMismatch: ( + thread: PersonaThread, + command: CommandContext & { readonly modelSelection?: ModelSelection | undefined }, + ): Effect.Effect => + reject(command, agentPersonaModelMismatchError(thread, command.modelSelection)), + }; + }); diff --git a/apps/server/src/j5/agents/agentPersonaRuntime.ts b/apps/server/src/j5/agents/agentPersonaRuntime.ts index 8ca48c33dcf1c..73b390c759fee 100644 --- a/apps/server/src/j5/agents/agentPersonaRuntime.ts +++ b/apps/server/src/j5/agents/agentPersonaRuntime.ts @@ -2,7 +2,11 @@ import * as Effect from "effect/Effect"; import type { OrchestrationV2AppThread } from "@t3tools/contracts"; import { ProviderAdapterV2RuntimePolicy } from "../../orchestration-v2/ProviderAdapter.ts"; import { getBuiltInAgentPersonaInstructions } from "./agentPersonaPrompts.ts"; -import { AgentPersonaLibraryError, type createAgentPersonaLibrary } from "./agentPersonaLibrary.ts"; +import { + AgentPersonaLibraryError, + makeAgentPersonaLibrary, + type createAgentPersonaLibrary, +} from "./agentPersonaLibrary.ts"; import { getAgentAuthorityRules } from "./agentPersonas.ts"; import { providerCanEnforceAgentPersonaAuthority, @@ -63,3 +67,23 @@ export const resolveAgentPersonaRuntimePolicy = ( }), ), ); + +export interface AgentPersonaRuntimePolicyInput { + readonly thread: OrchestrationV2AppThread; + readonly cwd: string | null; +} + +/** + * Resolver for the upstream RuntimePolicy layers: builds the library once and maps failures + * through the caller-supplied error constructor, so the upstream file adds no persona logic. + */ +export const makeAgentPersonaRuntimePolicyResolver = ( + toError: (input: AgentPersonaRuntimePolicyInput, cause: unknown) => E, +) => + Effect.gen(function* () { + const library = yield* makeAgentPersonaLibrary; + return (input: AgentPersonaRuntimePolicyInput) => + resolveAgentPersonaRuntimePolicy(input, library).pipe( + Effect.mapError((cause) => toError(input, cause)), + ); + }); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 84a0568e45584..284b8a771197e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -39,12 +39,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { makeAgentPersonaLibrary } from "../j5/agents/agentPersonaLibrary.ts"; -import { - agentPersonaModelMismatchError, - agentPersonaRouteLockedError, - guardAgentPersonaThreadCreate, -} from "../j5/agents/agentPersonaOrchestration.ts"; +import { makeAgentPersonaGuards } from "../j5/agents/agentPersonaOrchestration.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2 } from "./CommandPolicy.ts"; import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; @@ -544,7 +539,6 @@ function rootProviderThreadsForProvider( } const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(function* () { - const personaLibrary = yield* makeAgentPersonaLibrary; const checkpointService = yield* CheckpointServiceV2; const commandPolicy = yield* CommandPolicyV2; const contextHandoffService = yield* ContextHandoffServiceV2; @@ -553,6 +547,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const idAllocator = yield* IdAllocatorV2; const projectionStore = yield* ProjectionStoreV2; const providerAdapters = yield* ProviderAdapterRegistryV2; + const personaGuards = yield* makeAgentPersonaGuards({ + getDriver: (providerInstanceId) => + providerAdapters.get(providerInstanceId).pipe(Effect.map((adapter) => adapter.driver)), + adapterError: (command, providerInstanceId, cause) => + new OrchestratorProviderAdapterError({ + commandId: command.commandId, + providerInstanceId, + cause, + }), + dispatchError: (command, cause) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + }); const continuationRequests = yield* ProviderContinuationRequests; const providerSessions = yield* ProviderSessionManagerV2; const providerSwitchService = yield* ProviderSwitchServiceV2; @@ -1349,29 +1359,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command: Extract, events: Ref.Ref>, ) { - yield* guardAgentPersonaThreadCreate(command, personaLibrary, (providerInstanceId) => - providerAdapters.get(providerInstanceId).pipe( - Effect.map((adapter) => adapter.driver), - Effect.mapError( - (cause) => - new OrchestratorProviderAdapterError({ - commandId: command.commandId, - providerInstanceId, - cause, - }), - ), - ), - ).pipe( - Effect.catchTag( - "AgentPersonaLibraryError", - (cause) => - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause, - }), - ), - ); + yield* personaGuards.threadCreate(command); yield* Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -1561,14 +1549,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is not pinned and cannot be reordered.`, }); } - const personaRouteLocked = agentPersonaRouteLockedError(thread, command.type); - if (personaRouteLocked !== undefined) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: personaRouteLocked, - }); - } + yield* personaGuards.routeLocked(thread, command); if ( command.type === "thread.settle" && (projection.runs.some((run) => @@ -2927,17 +2908,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } } - const personaModelMismatch = agentPersonaModelMismatchError( - projection.thread, - command.modelSelection, - ); - if (personaModelMismatch !== undefined) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: personaModelMismatch, - }); - } + yield* personaGuards.modelMismatch(projection.thread, command); if (projection.thread.settledOverride !== null) { const now = yield* DateTime.now; const thread: OrchestrationV2AppThread = { diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.ts b/apps/server/src/orchestration-v2/RuntimePolicy.ts index 7addfb364a9c7..3a1bcb959e0b0 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.ts @@ -11,11 +11,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts"; -import { - makeAgentPersonaLibrary, - type createAgentPersonaLibrary, -} from "../j5/agents/agentPersonaLibrary.ts"; -import { resolveAgentPersonaRuntimePolicy } from "../j5/agents/agentPersonaRuntime.ts"; +import { makeAgentPersonaRuntimePolicyResolver } from "../j5/agents/agentPersonaRuntime.ts"; import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, @@ -62,20 +58,14 @@ export class RuntimePolicyV2 extends Context.Service, -) => - resolveAgentPersonaRuntimePolicy(input, library).pipe( - Effect.mapError( - (cause) => - new RuntimePolicyResolveError({ - projectId: input.thread.projectId, - providerInstanceId: input.thread.providerInstanceId, - cause, - }), - ), - ); +const resolveThreadPolicy = makeAgentPersonaRuntimePolicyResolver( + (input, cause) => + new RuntimePolicyResolveError({ + projectId: input.thread.projectId, + providerInstanceId: input.thread.providerInstanceId, + cause, + }), +); /** * IMPLEMENTATIONS @@ -83,10 +73,9 @@ const runtimePolicyForThread = ( export const layer: Layer.Layer = Layer.effect( RuntimePolicyV2, Effect.gen(function* () { - const library = yield* makeAgentPersonaLibrary; + const resolve = yield* resolveThreadPolicy; return RuntimePolicyV2.of({ - resolve: (input) => - runtimePolicyForThread({ thread: input.thread, cwd: input.thread.worktreePath }, library), + resolve: (input) => resolve({ thread: input.thread, cwd: input.thread.worktreePath }), }); }), ); @@ -99,7 +88,7 @@ export const layerFromProjectRepository: Layer.Layer< RuntimePolicyV2, Effect.gen(function* () { const projects = yield* ProjectionProjects.ProjectionProjectRepository; - const library = yield* makeAgentPersonaLibrary; + const resolve = yield* resolveThreadPolicy; return RuntimePolicyV2.of({ resolve: Effect.fn("RuntimePolicyV2.resolve")(function* (input) { const cwd = @@ -127,7 +116,7 @@ export const layerFromProjectRepository: Layer.Layer< }), ), )); - return yield* runtimePolicyForThread({ thread: input.thread, cwd }, library); + return yield* resolve({ thread: input.thread, cwd }); }), }); }),