Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions FORK.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
<AgentPersonaAssignmentControls
assignment={agentPersonaAssignment}
environmentId={props.environmentId}
threadId={props.selectedThread.id}
/>
) : (
<ComposerInlineControl
Expand Down
27 changes: 26 additions & 1 deletion apps/mobile/src/j5/agents/AgentPersonaAssignmentControls.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import {
AGENT_PERSONA_DRIFT_MESSAGE,
agentPersonaDrift,
presentAgentHandoff,
presentAgentPersonaAssignment,
} from "@t3tools/client-runtime/j5/agent-personas";
import type { EnvironmentId, OrchestrationV2AgentPersonaAssignment } from "@t3tools/contracts";
import type {
EnvironmentId,
OrchestrationV2AgentPersonaAssignment,
ThreadId,
} from "@t3tools/contracts";

import { ComposerInlineControl } from "../../components/ComposerToolbar";
import { useEnvironmentQuery } from "../../state/query";
Expand All @@ -16,8 +21,18 @@ export function AgentPersonaAssignmentControls(props: {
readonly environmentId?: EnvironmentId;
/** Present only for an unsent draft, where the choice can still be undone. */
readonly onClear?: () => void;
/** The server thread this control belongs to; enables the handoff artifact status. */
readonly threadId?: ThreadId;
}) {
const presentation = presentAgentPersonaAssignment(props.assignment);
const handoffs = useEnvironmentQuery(
props.environmentId === undefined || props.threadId === undefined
? null
: // Environment-wide and shared with every other handoff reader; refreshed by the server signal.
agentPersonaEnvironment.handoffs({ environmentId: props.environmentId, input: {} }),
);
const handoff = handoffs.data?.handoffs.find((entry) => entry.threadId === props.threadId);
const handoffPresentation = handoff === undefined ? null : presentAgentHandoff(handoff);
const catalog = useEnvironmentQuery(
props.environmentId === undefined
? null
Expand Down Expand Up @@ -50,6 +65,16 @@ export function AgentPersonaAssignmentControls(props: {
onPress={props.onClear}
/>
) : null}
{handoffPresentation ? (
<ComposerInlineControl
accessibilityLabel={`Handoff artifact: ${handoffPresentation.label}`}
accessibilityHint={handoffPresentation.detail}
icon={handoffPresentation.tone === "success" ? "doc.text" : "exclamationmark.triangle"}
label={handoffPresentation.label}
maxWidth={200}
static
/>
) : null}
{drift === "changed" ? (
<ComposerInlineControl
accessibilityLabel="Agent definition changed since launch"
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/j5/a2a/Migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ it.effect("tracks J5 A2A migrations independently from upstream migrations", ()
{ migration_id: 9, name: "SquadronProjectReferences" },
{ migration_id: 10, name: "OpenInboxCountIndex" },
{ migration_id: 11, name: "ReversibleLifecycle" },
{ migration_id: 12, name: "AgentHandoffs" },
]);
assert.deepStrictEqual(
migrationEntries.map(([id, name]) => [id, name]),
Expand All @@ -66,6 +67,7 @@ it.effect("tracks J5 A2A migrations independently from upstream migrations", ()
[9, "SquadronProjectReferences"],
[10, "OpenInboxCountIndex"],
[11, "ReversibleLifecycle"],
[12, "AgentHandoffs"],
],
);
}).pipe(Effect.provide(NodeSqliteClient.layerMemory())),
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/j5/a2a/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import Migration0007 from "./migrations/007_ParticipantPlacement.ts";
import Migration0008 from "./migrations/008_LifecycleClosure.ts";
import Migration0009 from "./migrations/009_SquadronProjectReferences.ts";
import Migration0011 from "./migrations/011_ReversibleLifecycle.ts";
import Migration0012 from "./migrations/012_AgentHandoffs.ts";
import Migration0010 from "./migrations/010_OpenInboxCountIndex.ts";

export const J5_A2A_MIGRATIONS_TABLE = "j5_a2a_migrations";

// Entries 1 through 10 are persisted migration history. Keep their file and manifest names unchanged;
// Entries 1 through 12 are persisted migration history. Keep their file and manifest names unchanged;
// applied migrations are skipped by id and never rerun.
export const migrationEntries = [
[1, "EpicCommunicationLedger", Migration0001],
Expand All @@ -29,6 +30,7 @@ export const migrationEntries = [
[9, "SquadronProjectReferences", Migration0009],
[10, "OpenInboxCountIndex", Migration0010],
[11, "ReversibleLifecycle", Migration0011],
[12, "AgentHandoffs", Migration0012],
] as const;

const makeMigrationLoader = (throughId?: number) =>
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/j5/a2a/mcp/claudeAllowedTools.ts
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"];
50 changes: 50 additions & 0 deletions apps/server/src/j5/a2a/mcp/codexToolApproval.test.ts
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({});
});
});
34 changes: 34 additions & 0 deletions apps/server/src/j5/a2a/mcp/codexToolApproval.ts
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 : {};
21 changes: 21 additions & 0 deletions apps/server/src/j5/a2a/migrations/012_AgentHandoffs.ts
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)`;
});
12 changes: 12 additions & 0 deletions apps/server/src/j5/a2a/runtimeLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import { layer as squadronThreadCreationServiceLayer } from "./SquadronThreadCre
import { layer as threadHomesServiceLayer } from "./ThreadHomesService.ts";
import { layer as spawnCompositionLayer } from "./SpawnCompositionService.ts";
import { layer as squadronJoinLayer } from "./SquadronJoinService.ts";
import { layer as agentHandoffNudgeQueueLayer } from "../agents/agentHandoffNudgeQueue.ts";
import { layer as agentHandoffNudgeWorkerLayer } from "../agents/agentHandoffNudgeWorker.ts";
import { layer as agentHandoffRefreshesLayer } from "../agents/agentHandoffRefreshes.ts";

/**
* The durable launch engine needs this subset before it can start preparing a
Expand Down Expand Up @@ -64,7 +67,16 @@ export const makeJ5A2AAuxiliaryLayer = (
const squadronJoinProvided = squadronJoinLayer.pipe(
Layer.provideMerge(homeRegistrationTransactionLayer),
);
// The saved-agent handoff worker drains the queue the run-finalization observer fills; the
// queue layer is the same instance server.ts provides to that observer.
const agentHandoffNudgeWorkerProvided = agentHandoffNudgeWorkerLayer.pipe(
Layer.provide(agentHandoffNudgeQueueLayer),
);
const runtimeWithoutClientReads = Layer.mergeAll(
agentHandoffNudgeWorkerProvided,
// Exported to the routes so the J5 WebSocket handler streams the same revision counter the
// observer bumps (server.ts provides this layer object to the observer; Effect memoizes it).
agentHandoffRefreshesLayer,
humanPersonRegistryLayer,
sendServiceLayer,
deliveryWorkerProvided,
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/j5/agents/agentHandoffNudgeQueue.ts
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>());
52 changes: 52 additions & 0 deletions apps/server/src/j5/agents/agentHandoffNudgeWorker.ts
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) =>
Comment thread
bryantderosier marked this conversation as resolved.
Resolved
`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,
);
}),
);
Loading
Loading