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
Binary file added .github/assets/swarm-enabled.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions apps/server/src/codexAppServerManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,39 @@ describe("sendTurn", () => {
});
});

it("passes Codex swarm mode developer instructions with the configured loop cap", async () => {
const { manager, context, sendRequest } = createSendTurnHarness();

await manager.sendTurn({
threadId: asThreadId("thread_1"),
input: "Implement this without guesswork",
interactionMode: "swarm",
swarmMaxLoops: 2,
});

expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
threadId: "thread_1",
input: [
{
type: "text",
text: "Implement this without guesswork",
text_elements: [],
},
],
model: "gpt-5.3-codex",
collaborationMode: {
mode: "default",
settings: {
model: "gpt-5.3-codex",
reasoning_effort: "medium",
developer_instructions: expect.stringMatching(
/Max loops: 2\..*must use real sub-agents.*Questionnaire.*Planner.*Coder.*Reviewer/s,
),
},
},
});
});

it("keeps the session model when interaction mode is set without an explicit model", async () => {
const { manager, context, sendRequest } = createSendTurnHarness();
context.session.model = "gpt-5.2-codex";
Expand Down
16 changes: 11 additions & 5 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
type CodexAccountSnapshot,
} from "./provider/codexAccount";
import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer";
import { buildCodexSwarmDeveloperInstructions } from "./provider/swarm";

export { buildCodexInitializeParams } from "./provider/codexAppServer";
export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount";
Expand Down Expand Up @@ -113,6 +114,7 @@ export interface CodexAppServerSendTurnInput {
readonly serviceTier?: string | null;
readonly effort?: string;
readonly interactionMode?: ProviderInteractionMode;
readonly swarmMaxLoops?: 1 | 2;
}

export interface CodexAppServerStartSessionInput {
Expand Down Expand Up @@ -365,9 +367,10 @@ export function normalizeCodexModelSlug(
}

function buildCodexCollaborationMode(input: {
readonly interactionMode?: "default" | "plan" | "ask" | "code" | "review";
readonly interactionMode?: "default" | "plan" | "ask" | "code" | "review" | "swarm";
readonly model?: string;
readonly effort?: string;
readonly swarmMaxLoops?: 1 | 2;
}):
| {
mode: "default" | "plan";
Expand All @@ -391,10 +394,12 @@ function buildCodexCollaborationMode(input: {
reasoning_effort: input.effort ?? "medium",
developer_instructions:
input.interactionMode === "plan"
? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS
: input.interactionMode === "review"
? CODEX_REVIEW_MODE_DEVELOPER_INSTRUCTIONS
: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS
: input.interactionMode === "review"
? CODEX_REVIEW_MODE_DEVELOPER_INSTRUCTIONS
: input.interactionMode === "swarm"
? buildCodexSwarmDeveloperInstructions(input.swarmMaxLoops ?? 1)
: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
},
};
}
Expand Down Expand Up @@ -756,6 +761,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
...(normalizedModel !== undefined ? { model: normalizedModel } : {}),
...(input.effort !== undefined ? { effort: input.effort } : {}),
...(input.swarmMaxLoops !== undefined ? { swarmMaxLoops: input.swarmMaxLoops } : {}),
});
if (collaborationMode) {
if (!turnStartParams.model) {
Expand Down
51 changes: 50 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ApprovalRequestId,
ProviderItemId,
ProviderRuntimeEvent,
type ServerSettings,
ThreadId,
} from "@t3tools/contracts";
import { assert, describe, it } from "@effect/vitest";
Expand Down Expand Up @@ -135,6 +136,7 @@ function makeHarness(config?: {
readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];
readonly cwd?: string;
readonly baseDir?: string;
readonly settingsOverrides?: Partial<ServerSettings>;
}) {
const query = new FakeClaudeQuery();
let createInput:
Expand Down Expand Up @@ -169,7 +171,7 @@ function makeHarness(config?: {
config?.baseDir ?? "/tmp",
),
),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(ServerSettingsService.layerTest(config?.settingsOverrides)),
Layer.provideMerge(NodeServices.layer),
),
query,
Expand Down Expand Up @@ -2548,6 +2550,53 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("injects the swarm workflow prompt and uses the configured loop cap", () => {
const harness = makeHarness({
settingsOverrides: {
swarmMaxLoops: 2,
},
});
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: "claudeAgent",
runtimeMode: "full-access",
});
yield* adapter.sendTurn({
threadId: session.threadId,
input: "Implement the auth flow",
interactionMode: "swarm",
attachments: [],
});

const promptText = yield* Effect.promise(() =>
readFirstPromptText(harness.getLastCreateQueryInput()),
);
const configuredAgents = (
harness.getLastCreateQueryInput()?.options as ClaudeQueryOptions & {
readonly agents?: Readonly<Record<string, unknown>>;
}
).agents;
assert.ok(promptText?.includes("SWARM MODE (max 2 loops)."));
assert.ok(promptText?.includes("Use real subagents in this exact order:"));
assert.ok(promptText?.includes("swarm-questionnaire"));
assert.ok(promptText?.includes("swarm-reviewer"));
assert.ok(promptText?.includes("User request:\nImplement the auth flow"));
assert.deepEqual(Object.keys(configuredAgents ?? {}).sort(), [
"swarm-coder",
"swarm-planner",
"swarm-questionnaire",
"swarm-reviewer",
]);
assert.deepEqual(harness.query.setPermissionModeCalls, ["bypassPermissions"]);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("does not call setPermissionMode when interactionMode is absent", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
65 changes: 60 additions & 5 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { getClaudeModelCapabilities } from "./ClaudeProvider.ts";
import {
buildClaudeSwarmAgents,
buildClaudeSwarmUserPromptPrefix,
normalizeSwarmMaxLoops,
} from "../swarm.ts";
import {
ProviderAdapterProcessError,
ProviderAdapterRequestError,
Expand Down Expand Up @@ -510,8 +515,32 @@ const CLAUDE_SETTING_SOURCES = [
"project",
"local",
] as const satisfies ReadonlyArray<SettingSource>;
type ClaudeSwarmAgentsOption = Readonly<
Record<
string,
{
readonly description: string;
readonly prompt: string;
readonly tools?: ReadonlyArray<string>;
readonly model?: "sonnet" | "opus" | "haiku" | "inherit";
}
>
>;
const CLAUDE_SWARM_AGENTS = buildClaudeSwarmAgents();

function buildPromptText(input: ProviderSendTurnInput): string {
function buildPromptText(
input: ProviderSendTurnInput,
options?: {
readonly swarmMaxLoops?: 1 | 2;
},
): string {
const rawText = input.input?.trim() ?? "";
const interactionText =
input.interactionMode === "swarm"
? `${buildClaudeSwarmUserPromptPrefix(options?.swarmMaxLoops ?? 1)}\n${
rawText.length > 0 ? rawText : "(no text provided; inspect the attachments and local context)"
}`
: rawText;
const rawEffort =
input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null;
const claudeModel =
Expand All @@ -523,7 +552,7 @@ function buildPromptText(input: ProviderSendTurnInput): string {
const trimmedEffort = trimOrNull(rawEffort);
const promptEffort =
trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null;
return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
return applyClaudePromptEffortPrefix(interactionText, promptEffort);
}

function buildUserMessage(input: {
Expand Down Expand Up @@ -559,9 +588,12 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
dependencies: {
readonly fileSystem: FileSystem.FileSystem;
readonly attachmentsDir: string;
readonly swarmMaxLoops?: 1 | 2;
},
) {
const text = buildPromptText(input);
const text = buildPromptText(input, {
swarmMaxLoops: dependencies.swarmMaxLoops,
});
const sdkContent: Array<Record<string, unknown>> = [];

if (text.length > 0) {
Expand Down Expand Up @@ -2710,7 +2742,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(fastMode ? { fastMode: true } : {}),
};

const queryOptions: ClaudeQueryOptions = {
const queryOptions: ClaudeQueryOptions & {
readonly agents?: ClaudeSwarmAgentsOption;
} = {
...(input.cwd ? { cwd: input.cwd } : {}),
...(apiModelId ? { model: apiModelId } : {}),
pathToClaudeCodeExecutable: claudeBinaryPath,
Expand All @@ -2727,6 +2761,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
canUseTool,
env: process.env,
...(input.cwd ? { additionalDirectories: [input.cwd] } : {}),
agents: CLAUDE_SWARM_AGENTS,
};

const queryRuntime = yield* Effect.try({
Expand Down Expand Up @@ -2864,6 +2899,21 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
const resolvedModelSelection = modelSelection
? resolveModelSelectionDefault(modelSelection)
: undefined;
const swarmMaxLoops =
input.interactionMode === "swarm"
? yield* serverSettingsService.getSettings.pipe(
Effect.map((settings) => normalizeSwarmMaxLoops(settings.swarmMaxLoops)),
Effect.mapError(
(error) =>
new ProviderAdapterProcessError({
provider: PROVIDER,
threadId: input.threadId,
detail: error.message,
cause: error,
}),
),
)
: undefined;

if (context.turnState) {
// Auto-close a stale synthetic turn (from background agent responses
Expand Down Expand Up @@ -2899,7 +2949,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
try: () => context.query.setPermissionMode("plan"),
catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause),
});
} else if (input.interactionMode === "default" || input.interactionMode === "code") {
} else if (
input.interactionMode === "default" ||
input.interactionMode === "code" ||
input.interactionMode === "swarm"
) {
yield* Effect.tryPromise({
try: () =>
context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"),
Expand Down Expand Up @@ -2942,6 +2996,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
const message = yield* buildUserMessageEffect(input, {
fileSystem,
attachmentsDir: serverConfig.attachmentsDir,
...(swarmMaxLoops !== undefined ? { swarmMaxLoops } : {}),
});

yield* Queue.offer(context.promptQueue, {
Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,39 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
);
});

const swarmManager = new FakeCodexManager();
const swarmLayer = it.layer(
makeCodexAdapterLive({ manager: swarmManager }).pipe(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest({ swarmMaxLoops: 2 })),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(NodeServices.layer),
),
);

swarmLayer("CodexAdapterLive swarm mode", (it) => {
it.effect("passes the configured swarm loop cap to the Codex manager", () =>
Effect.gen(function* () {
swarmManager.sendTurnImpl.mockClear();
const adapter = yield* CodexAdapter;

yield* adapter.sendTurn({
threadId: asThreadId("thread-swarm"),
input: "Ship the feature",
interactionMode: "swarm",
attachments: [],
});

assert.deepStrictEqual(swarmManager.sendTurnImpl.mock.calls[0]?.[0], {
threadId: asThreadId("thread-swarm"),
input: "Ship the feature",
interactionMode: "swarm",
swarmMaxLoops: 2,
});
}),
);
});

const lifecycleManager = new FakeCodexManager();
const lifecycleLayer = it.layer(
makeCodexAdapterLive({ manager: lifecycleManager }).pipe(
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { normalizeSwarmMaxLoops } from "../swarm.ts";
import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";

const PROVIDER = "codex" as const;
Expand Down Expand Up @@ -1483,6 +1484,21 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
(attachment) => resolveAttachment(input, attachment),
{ concurrency: 1 },
);
const swarmMaxLoops =
input.interactionMode === "swarm"
? yield* serverSettingsService.getSettings.pipe(
Effect.map((settings) => normalizeSwarmMaxLoops(settings.swarmMaxLoops)),
Effect.mapError(
(error) =>
new ProviderAdapterProcessError({
provider: PROVIDER,
threadId: input.threadId,
detail: error.message,
cause: error,
}),
),
)
: undefined;

return yield* Effect.tryPromise({
try: () => {
Expand All @@ -1498,6 +1514,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
...(input.interactionMode !== undefined
? { interactionMode: input.interactionMode }
: {}),
...(swarmMaxLoops !== undefined ? { swarmMaxLoops } : {}),
...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}),
};
return manager.sendTurn(managerInput);
Expand Down
Loading
Loading