Skip to content

Commit ffc2538

Browse files
authored
fix(server): support Prime Agent 0.8.0 (#71)
1 parent fe83b3c commit ffc2538

15 files changed

Lines changed: 704 additions & 97 deletions

apps/server/scripts/acp-mock-agent.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
4242
const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
4343
const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
4444
const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");
45+
const primeTerminalQuiescenceDelayMs = Number(
46+
process.env.T3_ACP_PRIME_TERMINAL_QUIESCENCE_DELAY_MS ?? "0",
47+
);
48+
const primeTerminalQuiescenceOutcome =
49+
process.env.T3_ACP_PRIME_TERMINAL_QUIESCENCE_OUTCOME === "error" ? "error" : "result";
50+
if (process.env.T3_ACP_ASSERT_TOP_LEVEL_ENV === "1") {
51+
const inheritedInternal = Object.keys(process.env).find(
52+
(name) => name.startsWith("PRIME_AGENT_INTERNAL_") || name === "RLM_DEPTH",
53+
);
54+
if (inheritedInternal !== undefined || process.env.RLM_MAX_DEPTH !== "4") {
55+
throw new Error("Mock ACP process inherited private Prime Agent worker context.");
56+
}
57+
}
58+
4559
const permissionOptionIds = {
4660
allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",
4761
allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
@@ -467,6 +481,59 @@ const program = Effect.gen(function* () {
467481
return yield* AcpError.AcpRequestError.internalError("Mock prompt failure");
468482
}
469483

484+
if (Number.isFinite(primeTerminalQuiescenceDelayMs) && primeTerminalQuiescenceDelayMs > 0) {
485+
const namespace = "ai.primeintellect.prime-agent";
486+
writeJsonRpcNotification("session/update", {
487+
sessionId: requestedSessionId,
488+
update: {
489+
sessionUpdate: "agent_message_chunk",
490+
content: { type: "text", text: "before terminal quiescence" },
491+
},
492+
});
493+
writeJsonRpcNotification("session/update", {
494+
sessionId: requestedSessionId,
495+
update: {
496+
sessionUpdate: "session_info_update",
497+
_meta: {
498+
[namespace]: {
499+
promptTurnId: promptCount,
500+
eventSequence: promptCount * 2 - 1,
501+
phase: "responseBoundary",
502+
outcome: primeTerminalQuiescenceOutcome,
503+
terminalQuiescenceExpected: true,
504+
},
505+
},
506+
},
507+
});
508+
const terminalPromptTurnId = promptCount;
509+
yield* Effect.sleep(primeTerminalQuiescenceDelayMs).pipe(
510+
Effect.andThen(
511+
Effect.sync(() =>
512+
writeJsonRpcNotification("session/update", {
513+
sessionId: requestedSessionId,
514+
update: {
515+
sessionUpdate: "session_info_update",
516+
_meta: {
517+
[namespace]: {
518+
promptTurnId: terminalPromptTurnId,
519+
eventSequence: terminalPromptTurnId * 2,
520+
phase: "terminalQuiescence",
521+
outcome: primeTerminalQuiescenceOutcome,
522+
quiescence: {
523+
outstandingSubagents: 0,
524+
remainingAutonomousContinuations: 0,
525+
},
526+
},
527+
},
528+
},
529+
}),
530+
),
531+
),
532+
Effect.forkDetach,
533+
);
534+
return { stopReason: "end_turn" };
535+
}
536+
470537
if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 1) {
471538
return {
472539
stopReason: "end_turn",

apps/server/src/provider/Layers/PrimeAgentAdapter.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as Effect from "effect/Effect";
1111
import * as Exit from "effect/Exit";
1212
import * as Fiber from "effect/Fiber";
1313
import * as Layer from "effect/Layer";
14+
import * as Option from "effect/Option";
1415
import * as Schema from "effect/Schema";
1516
import * as Stream from "effect/Stream";
1617

@@ -774,3 +775,65 @@ exec ${process.execPath} ${mockAgentPath} "$@"
774775
yield* Fiber.interrupt(cancelledEventFiber);
775776
}).pipe(Effect.scoped, Effect.provide(testLayer)),
776777
);
778+
779+
it.effect("waits for Prime Agent 0.8 terminal quiescence before settling ACP fallback turns", () =>
780+
Effect.gen(function* () {
781+
const tempDir = yield* Effect.promise(() =>
782+
NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "prime-agent-acp-quiescence-")),
783+
);
784+
const wrapperPath = NodePath.join(tempDir, "fake-prime-agent.sh");
785+
const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
786+
yield* Effect.promise(() =>
787+
NodeFSP.writeFile(
788+
wrapperPath,
789+
`#!/bin/sh
790+
exec ${process.execPath} ${mockAgentPath} "$@"
791+
`,
792+
"utf8",
793+
),
794+
);
795+
yield* Effect.promise(() => NodeFSP.chmod(wrapperPath, 0o755));
796+
797+
const adapter = yield* makePrimeAgentAdapter(decodeSettings({ binaryPath: wrapperPath }), {
798+
instanceId: ProviderInstanceId.make("primeAgent-quiescence"),
799+
environment: {
800+
...process.env,
801+
T3_ACP_PRIME_TERMINAL_QUIESCENCE_DELAY_MS: "500",
802+
T3_ACP_PRIME_TERMINAL_QUIESCENCE_OUTCOME: "error",
803+
T3_ACP_REQUEST_LOG_PATH: requestLogPath,
804+
T3_ACP_ASSERT_TOP_LEVEL_ENV: "1",
805+
PRIME_AGENT_INTERNAL_TEST_WORKER: "nested",
806+
RLM_DEPTH: "3",
807+
RLM_MAX_DEPTH: "4",
808+
},
809+
});
810+
const threadId = ThreadId.make("terminal-quiescence");
811+
const terminalFiber = yield* adapter.streamEvents.pipe(
812+
Stream.filter(
813+
(event): event is TurnCompletedEvent =>
814+
event.threadId === threadId && event.type === "turn.completed",
815+
),
816+
Stream.runHead,
817+
Effect.forkChild,
818+
);
819+
yield* Effect.yieldNow;
820+
yield* adapter.startSession({
821+
threadId,
822+
provider: ProviderDriverKind.make("primeAgent"),
823+
cwd: process.cwd(),
824+
runtimeMode: "full-access",
825+
});
826+
827+
yield* adapter.sendTurn({
828+
threadId,
829+
input: "wait for descendants",
830+
attachments: [],
831+
});
832+
const terminal = yield* Fiber.join(terminalFiber);
833+
assert.isTrue(Option.isSome(terminal));
834+
if (Option.isSome(terminal)) {
835+
assert.equal(terminal.value.payload.state, "failed");
836+
}
837+
yield* adapter.stopSession(threadId);
838+
}).pipe(Effect.scoped, Effect.provide(testLayer)),
839+
);

apps/server/src/provider/Layers/PrimeAgentAdapter.ts

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ import {
4444
import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
4545
import {
4646
makePrimeAgentAcpRuntime,
47+
parsePrimeAgentAcpTerminalUpdate,
4748
primeAgentLaunchArgsIssue,
49+
type PrimeAgentAcpTerminalUpdate,
4850
} from "../acp/PrimeAgentAcpSupport.ts";
4951
import type { PrimeAgentAdapterShape } from "../Services/PrimeAgentAdapter.ts";
5052
import { canonicalPrimeToolItemId } from "../prime/PrimeAgentDaemonRuntimeEvents.ts";
@@ -75,11 +77,19 @@ export interface PrimeAgentAdapterLiveOptions {
7577
readonly startupWarning?: string;
7678
}
7779

80+
type PrimeAgentAcpTerminalSettlement =
81+
| { readonly state: "settled"; readonly outcome: "result" | "error" }
82+
| { readonly state: "invalid" };
83+
7884
interface PrimeAgentActiveTurn {
7985
readonly id: TurnId;
8086
readonly cancellation: Deferred.Deferred<void>;
87+
readonly terminalQuiescence: Deferred.Deferred<PrimeAgentAcpTerminalSettlement>;
8188
cancellationRequested: boolean;
8289
hasPublicAssistantTextAfterLatestToolBoundary: boolean;
90+
nativePromptTurnId: number | undefined;
91+
lastNativeEventSequence: number | undefined;
92+
terminalQuiescenceExpected: boolean;
8393
}
8494

8595
interface PrimeAgentSessionContext {
@@ -95,6 +105,55 @@ interface PrimeAgentSessionContext {
95105
stopped: boolean;
96106
}
97107

108+
function observePrimeAgentAcpTerminalUpdate(
109+
ctx: PrimeAgentSessionContext | undefined,
110+
update: PrimeAgentAcpTerminalUpdate,
111+
): Effect.Effect<void> {
112+
const activeTurn = ctx?.activeTurn;
113+
if (activeTurn === undefined) return Effect.void;
114+
if (update.phase === "invalid") {
115+
activeTurn.terminalQuiescenceExpected = true;
116+
return Deferred.succeed(activeTurn.terminalQuiescence, { state: "invalid" }).pipe(
117+
Effect.asVoid,
118+
);
119+
}
120+
if (
121+
activeTurn.lastNativeEventSequence !== undefined &&
122+
update.eventSequence <= activeTurn.lastNativeEventSequence
123+
) {
124+
return Effect.void;
125+
}
126+
activeTurn.lastNativeEventSequence = update.eventSequence;
127+
128+
if (update.phase === "responseBoundary") {
129+
if (activeTurn.nativePromptTurnId !== undefined) {
130+
activeTurn.terminalQuiescenceExpected = true;
131+
return Deferred.succeed(activeTurn.terminalQuiescence, { state: "invalid" }).pipe(
132+
Effect.asVoid,
133+
);
134+
}
135+
activeTurn.nativePromptTurnId = update.promptTurnId;
136+
activeTurn.terminalQuiescenceExpected = update.terminalQuiescenceExpected;
137+
return Effect.void;
138+
}
139+
140+
if (
141+
activeTurn.nativePromptTurnId === undefined ||
142+
activeTurn.nativePromptTurnId !== update.promptTurnId ||
143+
!activeTurn.terminalQuiescenceExpected
144+
) {
145+
activeTurn.terminalQuiescenceExpected = true;
146+
return Deferred.succeed(activeTurn.terminalQuiescence, { state: "invalid" }).pipe(
147+
Effect.asVoid,
148+
);
149+
}
150+
activeTurn.terminalQuiescenceExpected = true;
151+
return Deferred.succeed(activeTurn.terminalQuiescence, {
152+
state: "settled",
153+
outcome: update.outcome,
154+
}).pipe(Effect.asVoid);
155+
}
156+
98157
export function parsePrimeAgentResumeMarker(raw: unknown): boolean {
99158
return isPrimeAgentCompatibleResumeCursor(raw);
100159
}
@@ -449,6 +508,7 @@ export function makePrimeAgentAdapter(
449508
);
450509

451510
const sessionScope = yield* Scope.make("sequential");
511+
let sessionContext: PrimeAgentSessionContext | undefined;
452512
let sessionScopeTransferred = false;
453513
yield* Effect.addFinalizer(() =>
454514
sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void),
@@ -467,6 +527,12 @@ export function makePrimeAgentAdapter(
467527
continueSession: parsePrimeAgentResumeMarker(input.resumeCursor),
468528
model,
469529
clientInfo: { name: "pylon", version: "0.0.0" },
530+
observeSessionUpdate: (notification) => {
531+
const update = parsePrimeAgentAcpTerminalUpdate(notification);
532+
return update === undefined
533+
? Effect.void
534+
: observePrimeAgentAcpTerminalUpdate(sessionContext, update);
535+
},
470536
...acpNativeLoggers,
471537
}).pipe(
472538
Effect.provideService(Crypto.Crypto, crypto),
@@ -524,6 +590,7 @@ export function makePrimeAgentAdapter(
524590
stopRequested: false,
525591
stopped: false,
526592
};
593+
sessionContext = ctx;
527594

528595
const notificationFiber = yield* Stream.runDrain(
529596
Stream.mapEffect(acp.getEvents(), (event) =>
@@ -777,8 +844,12 @@ export function makePrimeAgentAdapter(
777844
const activeTurn: PrimeAgentActiveTurn = {
778845
id: turnId,
779846
cancellation: yield* Deferred.make<void>(),
847+
terminalQuiescence: yield* Deferred.make<PrimeAgentAcpTerminalSettlement>(),
780848
cancellationRequested: false,
781849
hasPublicAssistantTextAfterLatestToolBoundary: false,
850+
nativePromptTurnId: undefined,
851+
lastNativeEventSequence: undefined,
852+
terminalQuiescenceExpected: false,
782853
};
783854
ctx.activeTurn = activeTurn;
784855
ctx.lastPlanFingerprint = undefined;
@@ -802,7 +873,7 @@ export function makePrimeAgentAdapter(
802873
turnId,
803874
payload: { model: ctx.session.model ?? "default" },
804875
});
805-
const result = yield* Effect.raceFirst(
876+
const promptExit = yield* Effect.raceFirst(
806877
ctx.acp.prompt({ prompt }),
807878
Deferred.await(activeTurn.cancellation).pipe(
808879
Effect.as({ stopReason: "cancelled" as const }),
@@ -811,13 +882,52 @@ export function makePrimeAgentAdapter(
811882
Effect.mapError((error) =>
812883
mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
813884
),
885+
Effect.exit,
814886
);
887+
// Prime Agent 0.8 publishes a response boundary before the ACP response and
888+
// an authoritative terminal-quiescence envelope after descendant work settles.
889+
// Older releases publish neither, so their prompt response remains terminal.
890+
// A stopped session has already shut down its notification consumer, so it
891+
// must not enqueue a barrier that can no longer be acknowledged.
892+
const promptCancelled =
893+
Exit.isSuccess(promptExit) && promptExit.value.stopReason === "cancelled";
894+
if (!promptCancelled && !activeTurn.cancellationRequested && !ctx.stopRequested) {
895+
yield* ctx.acp.drainEvents;
896+
}
897+
const terminal =
898+
activeTurn.terminalQuiescenceExpected && !promptCancelled
899+
? yield* Effect.raceFirst(
900+
Deferred.await(activeTurn.terminalQuiescence),
901+
Deferred.await(activeTurn.cancellation).pipe(
902+
Effect.as({ state: "cancelled" as const }),
903+
),
904+
)
905+
: undefined;
906+
if (terminal?.state === "invalid") {
907+
return yield* new ProviderAdapterRequestError({
908+
provider: PROVIDER,
909+
method: "session/prompt",
910+
detail: "Prime Agent returned invalid terminal-quiescence metadata.",
911+
});
912+
}
913+
if (Exit.isFailure(promptExit) && terminal?.state !== "cancelled") {
914+
return yield* Effect.failCause(promptExit.cause);
915+
}
916+
const result = Exit.isSuccess(promptExit)
917+
? promptExit.value
918+
: ({ stopReason: "cancelled" } as const);
815919
const settled = yield* settleActiveTurn(
816920
ctx,
817921
turnId,
818-
result.stopReason === "cancelled"
819-
? { state: "cancelled" }
820-
: { state: "completed", stopReason: result.stopReason ?? null },
922+
terminal?.state === "settled" && terminal.outcome === "error"
923+
? {
924+
state: "failed",
925+
errorMessage: PRIME_AGENT_TURN_FAILED,
926+
terminalFailure: true,
927+
}
928+
: terminal?.state === "cancelled" || result.stopReason === "cancelled"
929+
? { state: "cancelled" }
930+
: { state: "completed", stopReason: result.stopReason ?? null },
821931
true,
822932
);
823933
if (!settled && !activeTurn.cancellationRequested && !ctx.stopRequested) {

apps/server/src/provider/acp/AcpSessionRuntime.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export interface AcpSpawnInput {
5555
readonly args: ReadonlyArray<string>;
5656
readonly cwd?: string;
5757
readonly env?: NodeJS.ProcessEnv;
58+
/** Whether to merge the current process environment into `env`. Defaults to true. */
59+
readonly extendEnv?: boolean;
5860
}
5961

6062
export interface AcpSessionRuntimeOptions {
@@ -80,6 +82,10 @@ export interface AcpSessionRuntimeOptions {
8082
readonly shouldDiscardSessionUpdate?: (
8183
notification: EffectAcpSchema.SessionNotification,
8284
) => boolean;
85+
/** Observes accepted root-session metadata without copying it into the generic event stream. */
86+
readonly observeSessionUpdate?: (
87+
notification: EffectAcpSchema.SessionNotification,
88+
) => Effect.Effect<void, never>;
8389
}
8490

8591
export interface AcpSessionRequestLogEvent {
@@ -336,13 +342,17 @@ export const make = (
336342
const spawnCommand = yield* resolveSpawnCommand(
337343
options.spawn.command,
338344
options.spawn.args,
339-
options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {},
345+
options.spawn.env
346+
? { env: options.spawn.env, extendEnv: options.spawn.extendEnv ?? true }
347+
: {},
340348
);
341349
const child = yield* spawner
342350
.spawn(
343351
ChildProcess.make(spawnCommand.command, spawnCommand.args, {
344352
...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}),
345-
...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}),
353+
...(options.spawn.env
354+
? { env: options.spawn.env, extendEnv: options.spawn.extendEnv ?? true }
355+
: {}),
346356
shell: spawnCommand.shell,
347357
}),
348358
)
@@ -400,6 +410,9 @@ export const make = (
400410
) {
401411
return;
402412
}
413+
if (options.observeSessionUpdate !== undefined) {
414+
yield* options.observeSessionUpdate(notification);
415+
}
403416
yield* handleSessionUpdate({
404417
queue: eventQueue,
405418
modeStateRef,

0 commit comments

Comments
 (0)