Repository navigation
feat(agents): bind declared handoff artifacts to the shared artifacts system #142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
bryantderosier
merged 4 commits into
feature/agent-delegate-task
from
feature/agent-handoff-artifacts
Sep 15, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
679aada
feat(agents): bind declared handoff artifacts to the shared artifacts…
bryantderosier 8f2ff7a
fix(agents): make saved-agent handoffs writable, distinct per task, a…
bryantderosier eea29fe
fix(agents): one handoff reminder, honest gating, and a shared handof…
bryantderosier 10cdb3d
feat(artifacts): keep every version of a saved-agent handoff in one file
bryantderosier File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| /** | ||
| * t3-code MCP tools every read-only Claude thread may call in addition to upstream's read-only list. | ||
| * | ||
| * Claude runs read-only threads with permissionMode `dontAsk`, which silently denies any tool that | ||
| * is not pre-approved. Saved agents must write their declared handoff with `write_artifact`, and | ||
| * the read-only override has no persona knowledge, so this applies to every read-only Claude | ||
| * thread, saved agent or not. Artifacts live in server application storage, never in the sandboxed | ||
| * workspace, so pre-approving it does not widen what the sandbox lets the agent touch. Later J5 | ||
| * branches extend this list here rather than in the upstream adapter. | ||
| */ | ||
| export const J5_CLAUDE_MCP_ALLOWED_TOOLS: ReadonlyArray<string> = ["mcp__t3-code__write_artifact"]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { describe, expect, it } from "@effect/vitest"; | ||
|
|
||
| import type { ProviderAdapterV2RuntimePolicy } from "../../../orchestration-v2/ProviderAdapter.ts"; | ||
|
|
||
| import { | ||
| J5_CODEX_PREAPPROVED_TOOLS, | ||
| codexApprovalPolicyIsNever, | ||
| j5CodexT3McpServerConfig, | ||
| } from "./codexToolApproval.ts"; | ||
|
|
||
| const policy = (input: { | ||
| approvalPolicy?: unknown; | ||
| runtimeMode: ProviderAdapterV2RuntimePolicy["runtimeMode"]; | ||
| }): ProviderAdapterV2RuntimePolicy => ({ | ||
| runtimeMode: input.runtimeMode, | ||
| interactionMode: "default", | ||
| cwd: null, | ||
| ...(input.approvalPolicy === undefined ? {} : { approvalPolicy: input.approvalPolicy }), | ||
| }); | ||
|
|
||
| describe("Codex saved-agent tool approval", () => { | ||
| it("recognizes approval policy never, explicitly and as the full-access default", () => { | ||
| expect( | ||
| codexApprovalPolicyIsNever( | ||
| policy({ approvalPolicy: "never", runtimeMode: "approval-required" }), | ||
| ), | ||
| ).toBe(true); | ||
| expect(codexApprovalPolicyIsNever(policy({ runtimeMode: "full-access" }))).toBe(true); | ||
| expect(codexApprovalPolicyIsNever(policy({ runtimeMode: "approval-required" }))).toBe(false); | ||
| expect( | ||
| codexApprovalPolicyIsNever( | ||
| policy({ approvalPolicy: "on-request", runtimeMode: "full-access" }), | ||
| ), | ||
| ).toBe(false); | ||
| expect(codexApprovalPolicyIsNever(undefined)).toBe(false); | ||
| }); | ||
|
|
||
| it("pre-approves only the listed tool, and only when approvals are off", () => { | ||
| expect(J5_CODEX_PREAPPROVED_TOOLS).toEqual(["write_artifact"]); | ||
| expect( | ||
| j5CodexT3McpServerConfig( | ||
| policy({ approvalPolicy: "never", runtimeMode: "approval-required" }), | ||
| ), | ||
| ).toEqual({ | ||
| tools: { write_artifact: { approval_mode: "approve" } }, | ||
| }); | ||
| expect(j5CodexT3McpServerConfig(policy({ runtimeMode: "approval-required" }))).toEqual({}); | ||
| expect(j5CodexT3McpServerConfig(undefined)).toEqual({}); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import type { ProviderAdapterV2RuntimePolicy } from "../../../orchestration-v2/ProviderAdapter.ts"; | ||
|
|
||
| /** | ||
| * t3-code MCP tools pre-approved for every Codex thread running under approval policy "never". | ||
| * Codex 0.153+ refuses any MCP tool that is not annotated read-only under that policy ("MCP tool | ||
| * call requires approval, but approval policy is never"); every saved-agent runtime policy sends | ||
| * "never", and so does an ordinary full-access thread, so this is not persona-specific. `write_artifact` | ||
| * is annotated destructive but writes only to server application storage, never the sandboxed | ||
| * workspace, so pre-approving it keeps the workspace boundary intact while letting a saved agent | ||
| * deliver its handoff. This is the per-tool form (`mcp_servers.<id>.tools.<tool>.approval_mode`), | ||
| * not `default_tools_approval_mode`, which would also wave through worktree handoff, browser | ||
| * preview, and scheduling. | ||
| */ | ||
| export const J5_CODEX_PREAPPROVED_TOOLS: ReadonlyArray<string> = ["write_artifact"]; | ||
|
|
||
| export const J5_CODEX_T3_MCP_SERVER_CONFIG = { | ||
| tools: Object.fromEntries( | ||
| J5_CODEX_PREAPPROVED_TOOLS.map((name) => [name, { approval_mode: "approve" as const }]), | ||
| ), | ||
| }; | ||
|
|
||
| /** True when Codex will run this turn with approval policy "never" (explicitly, or by default in full access). */ | ||
| export const codexApprovalPolicyIsNever = ( | ||
| runtimePolicy: ProviderAdapterV2RuntimePolicy | undefined, | ||
| ): boolean => | ||
| runtimePolicy !== undefined && | ||
| (runtimePolicy.approvalPolicy === "never" || | ||
| (runtimePolicy.approvalPolicy === undefined && runtimePolicy.runtimeMode === "full-access")); | ||
|
|
||
| /** Spread into the injected `mcp_servers["t3-code"]` entry; empty unless approvals are off. */ | ||
| export const j5CodexT3McpServerConfig = ( | ||
| runtimePolicy: ProviderAdapterV2RuntimePolicy | undefined, | ||
| ): typeof J5_CODEX_T3_MCP_SERVER_CONFIG | Record<never, never> => | ||
| codexApprovalPolicyIsNever(runtimePolicy) ? J5_CODEX_T3_MCP_SERVER_CONFIG : {}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import * as Effect from "effect/Effect"; | ||
| import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
|
|
||
| /** One row per saved-agent task with a declared output artifact; status follows the run-end check. */ | ||
| export default Effect.gen(function* () { | ||
| const sql = yield* SqlClient.SqlClient; | ||
|
|
||
| yield* sql` | ||
| CREATE TABLE j5_agent_handoffs ( | ||
| thread_id TEXT PRIMARY KEY, | ||
| project_id TEXT NOT NULL, | ||
| persona_id TEXT NOT NULL, | ||
| artifact TEXT NOT NULL, | ||
| path TEXT NOT NULL, | ||
| status TEXT NOT NULL, | ||
| run_id TEXT, | ||
| checked_at TEXT NOT NULL | ||
| ) | ||
| `; | ||
| yield* sql`CREATE INDEX j5_agent_handoffs_project_idx ON j5_agent_handoffs(project_id, checked_at)`; | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import type { ProjectId, RunId, ThreadId } from "@t3tools/contracts"; | ||
| import * as Context from "effect/Context"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Queue from "effect/Queue"; | ||
|
|
||
| export interface AgentHandoffNudge { | ||
| readonly projectId: ProjectId; | ||
| readonly threadId: ThreadId; | ||
| readonly runId: RunId; | ||
| readonly personaId: string; | ||
| readonly artifact: string; | ||
| readonly path: string; | ||
| } | ||
|
|
||
| /** | ||
| * Hands "ask the agent for its missing handoff" from the run-finalization observer, which | ||
| * runs inside the orchestration runtime, to a worker that can reach ThreadManagement. The | ||
| * single layer instance is shared by reference between both providers. | ||
| */ | ||
| export class AgentHandoffNudgeQueue extends Context.Service< | ||
| AgentHandoffNudgeQueue, | ||
| Queue.Queue<AgentHandoffNudge> | ||
| >()("t3/j5/agents/agentHandoffNudgeQueue") {} | ||
|
|
||
| export const layer = Layer.effect(AgentHandoffNudgeQueue, Queue.unbounded<AgentHandoffNudge>()); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { CommandId, MessageId } from "@t3tools/contracts"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Queue from "effect/Queue"; | ||
|
|
||
| import { ThreadManagementService } from "../../orchestration-v2/ThreadManagementService.ts"; | ||
| import { AgentHandoffNudgeQueue, type AgentHandoffNudge } from "./agentHandoffNudgeQueue.ts"; | ||
| import { agentHandoffLogicalPath } from "./agentPersonaArtifacts.ts"; | ||
|
|
||
| export const agentHandoffNudgeText = (nudge: AgentHandoffNudge) => | ||
| `Your run ended without the declared ${nudge.artifact} handoff. Write it now with write_artifact to exactly \`${nudge.path}\` (it will appear as \`${agentHandoffLogicalPath(nudge.path)}\`), covering the required contents from your instructions, then finish. This is the only reminder; a run that ends without it is recorded as a missing handoff.`; | ||
|
|
||
| /** | ||
| * Drains the nudge queue where ThreadManagement is available and sends the one follow-up | ||
| * message into the saved agent's own thread. Ids derive from the run so a redelivery of the | ||
| * same finalization cannot start a second turn. | ||
| */ | ||
| export const layer = Layer.effectDiscard( | ||
| Effect.gen(function* () { | ||
| const nudges = yield* AgentHandoffNudgeQueue; | ||
| const threads = yield* ThreadManagementService; | ||
| const deliver = Effect.fn("j5.agentHandoffNudgeWorker.deliver")(function* ( | ||
| nudge: AgentHandoffNudge, | ||
| ) { | ||
| yield* threads.sendToThread({ | ||
| projectId: nudge.projectId, | ||
| commandId: CommandId.make(`agent-handoff-nudge:${nudge.runId}`), | ||
| threadId: nudge.threadId, | ||
| messageId: MessageId.make(`agent-handoff-nudge:${nudge.runId}`), | ||
| text: agentHandoffNudgeText(nudge), | ||
| attachments: [], | ||
| mode: "queue", | ||
| createdBy: "system", | ||
| creationSource: "server", | ||
| }); | ||
| }); | ||
| yield* Queue.take(nudges).pipe( | ||
| Effect.flatMap((nudge) => | ||
| deliver(nudge).pipe( | ||
| Effect.catchCause((cause) => | ||
| Effect.logWarning("j5.agent-handoff.nudge-failed", { | ||
| cause, | ||
| threadId: nudge.threadId, | ||
| }), | ||
| ), | ||
| ), | ||
| ), | ||
| Effect.forever, | ||
| Effect.forkScoped, | ||
| ); | ||
| }), | ||
| ); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.