@junghanacs/entwurf 0.51.0 → 0.53.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/BASELINE.md +116 -397
- package/CHANGELOG.md +191 -2
- package/CONTRIBUTING.md +4 -4
- package/DELIVERY.md +2 -2
- package/ENTWURF-CLI.json +1 -1
- package/ENTWURF.json +10 -4
- package/README.md +11 -8
- package/VERIFY.md +333 -143
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -1
- package/demo/demo.sh +1 -1
- package/docs/acp-backend-rail.md +248 -34
- package/docs/setup-clean-host.md +75 -80
- package/mcp/entwurf-bridge/dist/ENTWURF.json +10 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +90 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +4 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +15 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +9 -12
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +41 -56
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +33 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +4 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +34 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -1
- package/package.json +14 -14
- package/pi/pi-durable/carrier/harness-setup.js +1 -1
- package/pi/pi-durable/carrier/subagent.js +1 -1
- package/pi/pi-durable/overlay/upstream-pin.json +15 -15
- package/pi/settings.reference.json +0 -1
- package/pi-extensions/acp-provider.ts +12 -6
- package/pi-extensions/lib/acp/acp-client.ts +6 -7
- package/pi-extensions/lib/acp/backend-adapter.ts +139 -15
- package/pi-extensions/lib/acp/backend.ts +261 -32
- package/pi-extensions/lib/acp/config.ts +4 -9
- package/pi-extensions/lib/acp/context.ts +17 -0
- package/pi-extensions/lib/acp/event-mapper.ts +236 -17
- package/pi-extensions/lib/acp/model-ids.js +9 -12
- package/pi-extensions/lib/acp/models.ts +46 -57
- package/pi-extensions/lib/acp/tool-surface.ts +34 -6
- package/pi-extensions/lib/entwurf-control-rpc.ts +5 -1
- package/pi-extensions/lib/fresh-call-composition.ts +37 -3
- package/pi-extensions/lib/herdr-fresh-call.ts +3 -1
- package/pi-extensions/lib/meta-mailbox-body.ts +1 -1
- package/pi-extensions/lib/mux-fresh-call.ts +3 -1
- package/pi-extensions/meta-bridge-pi-durable.ts +1 -1
- package/run.sh +62 -120
- package/scripts/check-acp-carrier-augment.ts +3 -21
- package/scripts/check-acp-prompt-lifecycle.ts +24 -3
- package/scripts/check-acp-provider-surface.ts +17 -26
- package/scripts/check-acp-session-reuse.ts +23 -4
- package/scripts/check-acp-stop-reason.ts +22 -3
- package/scripts/check-acp-stream-hooks.ts +24 -3
- package/scripts/check-acp-usage-accounting.ts +53 -24
- package/scripts/check-copilot-receive-arm.ts +19 -2
- package/scripts/check-entwurf-v2-surface.ts +7 -21
- package/scripts/check-fresh-call-dispatch.ts +9 -3
- package/scripts/check-gate-qualification.ts +6 -6
- package/scripts/check-herdr-fresh-call.ts +1 -0
- package/scripts/check-install-surface.ts +6 -0
- package/scripts/check-pi-durable-send.ts +4 -1
- package/scripts/check-probe-ordering.ts +34 -24
- package/scripts/check-release-gate-outcomes.ts +9 -23
- package/scripts/lib/codex-fresh-source-receipts.ts +12 -6
- package/scripts/lib/probe-cli-target.ts +3 -2
- package/scripts/mutants/acp-augment.json +0 -26
- package/scripts/mutants/acp-cortex.json +10 -183
- package/scripts/mutants/acp-foreground-only.json +11 -0
- package/scripts/mutants/acp-launch-namespace.json +3 -1
- package/scripts/mutants/acp-prompt-lifecycle.json +2 -2
- package/scripts/mutants/acp-set-model-readback.json +488 -0
- package/scripts/mutants/agent-guidance.json +2 -2
- package/scripts/mutants/codex-native.json +2 -2
- package/scripts/mutants/control-send-receipt.json +47 -7
- package/scripts/mutants/mux-fresh-call.json +12 -1
- package/scripts/mutants/pack-install.json +4 -4
- package/scripts/mutants/pi-floor.json +8 -10
- package/scripts/mutants/release-gate.json +3 -3
- package/scripts/mutants/v2-surface.json +4 -6
- package/scripts/raw-acp-compaction-measure/probe.ts +8 -2
- package/scripts/smoke-acp-cortex-live.ts +4 -408
- package/scripts/smoke-acp-ordering-probe-live.ts +7 -1
- package/scripts/smoke-acp-session-reuse-live.ts +11 -2
- package/scripts/smoke-acp-v2-send-live.ts +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +4 -4
- package/scripts/check-acp-cortex.ts +0 -710
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
appendAssistantMessageDiagnostic,
|
|
50
50
|
createAssistantMessageDiagnostic,
|
|
51
51
|
createAssistantMessageEventStream,
|
|
52
|
+
getCurrentSystemPrompt,
|
|
52
53
|
getCurrentTools,
|
|
53
54
|
} from "@earendil-works/pi-ai";
|
|
54
55
|
import {
|
|
@@ -61,6 +62,7 @@ import { prependNewPromptAugment } from "./augment.js";
|
|
|
61
62
|
import {
|
|
62
63
|
ACP_PROMPT_REJECTION_DIAGNOSTIC,
|
|
63
64
|
type AcpBackendAdapter,
|
|
65
|
+
assertSupportedDeclaredBackend,
|
|
64
66
|
resolveAcpBackendAdapter,
|
|
65
67
|
} from "./backend-adapter.js";
|
|
66
68
|
import {
|
|
@@ -90,7 +92,7 @@ import {
|
|
|
90
92
|
resolveLifecyclePolicy,
|
|
91
93
|
writeSessionRecord,
|
|
92
94
|
} from "./session-store.js";
|
|
93
|
-
import { assertExcludeToolsHonored } from "./tool-surface.js";
|
|
95
|
+
import { assertExcludeToolsHonored, isPiSummaryRequest } from "./tool-surface.js";
|
|
94
96
|
|
|
95
97
|
// Bootstrap boundaries ONLY. initialize / newSession / set-model are handshake
|
|
96
98
|
// steps that make no model progress, so a stuck one is a dead session and a cold
|
|
@@ -310,6 +312,9 @@ interface BridgeSession {
|
|
|
310
312
|
acpSessionId: string;
|
|
311
313
|
bridgeConfigSignature: string;
|
|
312
314
|
contextMessageSignatures: string[];
|
|
315
|
+
/** The backend thinking value the driver last read back on this session (#142); a fact about the
|
|
316
|
+
* live session, never part of the reuse signature. Undefined until one is set. */
|
|
317
|
+
thinkingValue?: string;
|
|
313
318
|
alive: boolean;
|
|
314
319
|
busy: boolean;
|
|
315
320
|
/** Mutable per-turn router — see the CRITICAL note in the file header. */
|
|
@@ -449,6 +454,40 @@ let cleanupRegistered = false;
|
|
|
449
454
|
* `process.once` would leak an EventEmitter listener per turn — GPT c617cb. The
|
|
450
455
|
* handler is sync (the `exit` event forbids async work).
|
|
451
456
|
*/
|
|
457
|
+
/**
|
|
458
|
+
* Close a retained session we are leaving on purpose: mark it retiring (so its exit is not
|
|
459
|
+
* announced as an unexpected end), drop it from the map, and tear its child down.
|
|
460
|
+
*/
|
|
461
|
+
function abandonRetainedSession(session: BridgeSession): void {
|
|
462
|
+
session.alive = false;
|
|
463
|
+
session.retiring = true;
|
|
464
|
+
if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
|
|
465
|
+
retainedChildren.delete(session.child);
|
|
466
|
+
session.connection.close?.();
|
|
467
|
+
teardownChild(session.child);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* #163, GLG 2026-11-14: after a successful Pi compaction the retained Claude child of that Pi
|
|
472
|
+
* session is closed at once, not kept until the next turn's prefix break discards it. The next
|
|
473
|
+
* turn then opens a new child from Pi's compacted transcript (summary + kept tail) — the path
|
|
474
|
+
* `decideBootstrap` already takes when the stored prefix no longer matches.
|
|
475
|
+
*
|
|
476
|
+
* Keyed exactly as an ordinary turn keys its session: Pi passes its session manager's id as the
|
|
477
|
+
* stream `sessionId` (pi-coding-agent 1.5.2 `dist/core/sdk.js:276`), and `resolveSessionKey`
|
|
478
|
+
* turns that into `pi:<id>`. A busy or in-flight session is left alone — Pi aborts the running
|
|
479
|
+
* turn before compacting (`dist/core/agent-session.js:2327`), so a busy one is not the session
|
|
480
|
+
* that compaction left behind, and the prefix break still covers it. Returns whether a child
|
|
481
|
+
* was closed.
|
|
482
|
+
*/
|
|
483
|
+
export function closeRetainedAcpSession(piSessionId: string): boolean {
|
|
484
|
+
const key = `pi:${piSessionId}`;
|
|
485
|
+
const session = bridgeSessions.get(key);
|
|
486
|
+
if (!session || session.busy || inFlightKeys.has(key)) return false;
|
|
487
|
+
abandonRetainedSession(session);
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
|
|
452
491
|
function registerGlobalCleanup(): void {
|
|
453
492
|
if (cleanupRegistered) return;
|
|
454
493
|
cleanupRegistered = true;
|
|
@@ -757,6 +796,34 @@ function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
|
|
|
757
796
|
});
|
|
758
797
|
}
|
|
759
798
|
|
|
799
|
+
// Reject as soon as the operator aborts, so a pre-prompt step on a LIVE session
|
|
800
|
+
// (reuse has no bootstrap child to tear down) settles on the abort instead of on
|
|
801
|
+
// the driver's answer or the bootstrap timeout. The step itself is abandoned, not
|
|
802
|
+
// cancelled: the caller drops the session, since what the driver now holds is
|
|
803
|
+
// unknown (#142, Dot review of the reuse effort set).
|
|
804
|
+
function untilAborted<T>(p: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
805
|
+
if (!signal) return p;
|
|
806
|
+
let onAbort: (() => void) | undefined;
|
|
807
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
808
|
+
onAbort = () => reject(new Error("entwurf: aborted by the operator before the prompt was sent"));
|
|
809
|
+
if (signal.aborted) onAbort();
|
|
810
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
811
|
+
});
|
|
812
|
+
return Promise.race([p, aborted]).finally(() => {
|
|
813
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// What an operator who pressed ESC needs before speaking again (#142): the turn
|
|
818
|
+
// has stopped, nothing already done was undone, and whether the next message
|
|
819
|
+
// continues this live ACP session or opens a new one. Said only after the turn
|
|
820
|
+
// settled, and only about the live session — not about any private transcript.
|
|
821
|
+
export const ACP_CANCEL_KEPT_NOTICE = "stopped — changes already made stay; next message continues this session";
|
|
822
|
+
export const ACP_CANCEL_CLOSED_NOTICE = "stopped — changes already made stay; next message opens a new session";
|
|
823
|
+
/** #163: Pi's summary turn ended as `cancelled` with no abort from Pi. Kept non-transient for pi's retry classifier. */
|
|
824
|
+
export const ACP_SUMMARY_CANCELLED_MESSAGE =
|
|
825
|
+
"ACP backend ended the summary turn as cancelled although Pi did not abort it; the summary is incomplete and is not kept";
|
|
826
|
+
|
|
760
827
|
/**
|
|
761
828
|
* Await ONE ACP prompt with NO wall-clock deadline — the turn ends on lifecycle
|
|
762
829
|
* events only. Shared by the new and the reuse turn so both close the same way.
|
|
@@ -1151,6 +1218,8 @@ export function streamAcpTurn(
|
|
|
1151
1218
|
| undefined;
|
|
1152
1219
|
const cwd = opts?.cwd ?? process.cwd();
|
|
1153
1220
|
const signal = opts?.signal;
|
|
1221
|
+
/** #163: this turn is Pi's one-shot summary request (set by runSummaryTurn). */
|
|
1222
|
+
let summaryTurnActive = false;
|
|
1154
1223
|
|
|
1155
1224
|
stream.push({ type: "start", partial: state.output });
|
|
1156
1225
|
|
|
@@ -1420,7 +1489,17 @@ export function streamAcpTurn(
|
|
|
1420
1489
|
function finishSuccess(adapter: AcpBackendAdapter, session: BridgeSession, promptResult: AcpPromptResponse): void {
|
|
1421
1490
|
sealTurnUsage(adapter, session, promptResult);
|
|
1422
1491
|
finalizeAcpStreamState(state);
|
|
1423
|
-
|
|
1492
|
+
let verdict = mapPromptStopReason(promptResult?.stopReason);
|
|
1493
|
+
// #163: Pi keeps an `aborted` summary as a checkpoint — only `error` and `length` are refused
|
|
1494
|
+
// (pi-coding-agent 1.5.2 `compaction.js:454-462`). A summary the backend cancelled while
|
|
1495
|
+
// nobody aborted it is incomplete text, so it is sealed as an error, never stored.
|
|
1496
|
+
if (summaryTurnActive && verdict.stopReason === "aborted" && !signal?.aborted) {
|
|
1497
|
+
verdict = {
|
|
1498
|
+
stopReason: "error",
|
|
1499
|
+
rawStopReason: verdict.rawStopReason,
|
|
1500
|
+
errorMessage: ACP_SUMMARY_CANCELLED_MESSAGE,
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1424
1503
|
if (verdict.rawStopReason !== undefined) state.output.rawStopReason = verdict.rawStopReason;
|
|
1425
1504
|
if (signal?.aborted || verdict.stopReason === "aborted") {
|
|
1426
1505
|
state.output.stopReason = "aborted";
|
|
@@ -1524,6 +1603,12 @@ export function streamAcpTurn(
|
|
|
1524
1603
|
// agree, else fail loud — a mismatch means the settings and the requested model
|
|
1525
1604
|
// disagree about which backend runs, and silently trusting the model id would
|
|
1526
1605
|
// hide an operator typo. Routing authority stays single (the model id).
|
|
1606
|
+
try {
|
|
1607
|
+
assertSupportedDeclaredBackend(config.backend);
|
|
1608
|
+
} catch (err) {
|
|
1609
|
+
finishError(err, false);
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1527
1612
|
if (config.backend !== undefined && config.backend !== adapter.backend) {
|
|
1528
1613
|
finishError(
|
|
1529
1614
|
new Error(
|
|
@@ -1547,23 +1632,35 @@ export function streamAcpTurn(
|
|
|
1547
1632
|
// child will still expose (declared != actual), fail fast into the stream
|
|
1548
1633
|
// rather than lie to the model. Uses the RESOLVED tool surface (S2g) so an
|
|
1549
1634
|
// operator-narrowed `tools` is what the truthfulness check honors.
|
|
1635
|
+
//
|
|
1636
|
+
// pi 0.86 folded `Context.systemPrompt`/`Context.tools` into a leading
|
|
1637
|
+
// `role:"system"` message (`normalizeContext`, pi-ai
|
|
1638
|
+
// `utils/transcript.ts`), so a provider only ever sees a
|
|
1639
|
+
// `TranscriptContext`. The active surface is therefore REPLAYED from the
|
|
1640
|
+
// system messages — `getCurrentTools` applies every toolsAdded/toolsRemoved
|
|
1641
|
+
// delta in order. No `?? [...PI_BUILTIN_BACKED_TOOLS]` fallback: under 0.86
|
|
1642
|
+
// the old `context.tools` read was ALWAYS undefined, so that fallback
|
|
1643
|
+
// silently declared the full builtin set and the truthfulness preflight
|
|
1644
|
+
// could never fire (false green). An empty replayed list on an ordinary turn
|
|
1645
|
+
// means the operator excluded everything — keep that honest and reject.
|
|
1646
|
+
//
|
|
1647
|
+
// #163: the one empty surface that is NOT refused is Pi's own summarization
|
|
1648
|
+
// request (`isPiSummaryRequest`). It is honoured rather than refused: the
|
|
1649
|
+
// summary turn below runs on a child that is given no tools at all, so the
|
|
1650
|
+
// declared surface (none) is the actual one.
|
|
1651
|
+
let summaryTurn: boolean;
|
|
1550
1652
|
try {
|
|
1551
|
-
// pi 0.86 folded `Context.systemPrompt`/`Context.tools` into a leading
|
|
1552
|
-
// `role:"system"` message (`normalizeContext`, pi-ai
|
|
1553
|
-
// `utils/transcript.ts`), so a provider only ever sees a
|
|
1554
|
-
// `TranscriptContext`. The active surface is therefore REPLAYED from the
|
|
1555
|
-
// system messages — `getCurrentTools` applies every toolsAdded/toolsRemoved
|
|
1556
|
-
// delta in order. No `?? [...PI_BUILTIN_BACKED_TOOLS]` fallback: under 0.86
|
|
1557
|
-
// the old `context.tools` read was ALWAYS undefined, so that fallback
|
|
1558
|
-
// silently declared the full builtin set and the truthfulness preflight
|
|
1559
|
-
// could never fire (false green). An empty replayed list means the operator
|
|
1560
|
-
// excluded everything — keep that honest and let the preflight reject.
|
|
1561
1653
|
const activeToolNames = getCurrentTools(context.messages).map((t) => t.name);
|
|
1562
|
-
|
|
1654
|
+
summaryTurn = isPiSummaryRequest(activeToolNames, opts?.cacheRetention);
|
|
1655
|
+
if (!summaryTurn) assertExcludeToolsHonored(activeToolNames, { backend: adapter.backend, tools: config.tools });
|
|
1563
1656
|
} catch (err) {
|
|
1564
1657
|
finishError(err, false);
|
|
1565
1658
|
return;
|
|
1566
1659
|
}
|
|
1660
|
+
if (summaryTurn) {
|
|
1661
|
+
await runSummaryTurn(config, adapter, nativeModelId);
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1567
1664
|
|
|
1568
1665
|
const policy = deps.lifecyclePolicy();
|
|
1569
1666
|
const sessionKey = resolveSessionKey(opts, cwd);
|
|
@@ -1586,6 +1683,24 @@ export function streamAcpTurn(
|
|
|
1586
1683
|
finishError(err, false);
|
|
1587
1684
|
return;
|
|
1588
1685
|
}
|
|
1686
|
+
// #142: pi's thinking level → the backend's own value, resolved BEFORE any process or session is
|
|
1687
|
+
// touched, so an unsupported level refuses with nothing to unwind. A backend without the
|
|
1688
|
+
// capability (cortex) resolves nothing and keeps its old behaviour. The value is deliberately NOT
|
|
1689
|
+
// in the reuse signature: changing it must not trade the live Claude conversation for a new one,
|
|
1690
|
+
// so it is set on the live session before the prompt instead (runNewTurn / runReuseTurn).
|
|
1691
|
+
let thinkingValue: string | undefined;
|
|
1692
|
+
if (adapter.resolveThinking) {
|
|
1693
|
+
try {
|
|
1694
|
+
thinkingValue = adapter.resolveThinking({
|
|
1695
|
+
modelId: model.id,
|
|
1696
|
+
reasoning: opts?.reasoning,
|
|
1697
|
+
thinkingLevelMap: model.thinkingLevelMap,
|
|
1698
|
+
});
|
|
1699
|
+
} catch (err) {
|
|
1700
|
+
finishError(err, false);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1589
1704
|
// S2g: the signature folds the FULL resolved config (mcpServersHash + tool
|
|
1590
1705
|
// surface + skillPlugins + flags) so any operator config change invalidates
|
|
1591
1706
|
// a reused session; the per-session envelope is excluded (runtime, not config).
|
|
@@ -1651,14 +1766,7 @@ export function streamAcpTurn(
|
|
|
1651
1766
|
// session (incompatible drift / stale-dead) — the model-lock throw already
|
|
1652
1767
|
// returned above, so this is never a "leave it alone" case. Close the old
|
|
1653
1768
|
// connection + child so it is not orphaned in retainedChildren (GPT blocker 2).
|
|
1654
|
-
if (decision.path === "new" && existing)
|
|
1655
|
-
existing.alive = false;
|
|
1656
|
-
existing.retiring = true;
|
|
1657
|
-
if (bridgeSessions.get(sessionKey) === existing) bridgeSessions.delete(sessionKey);
|
|
1658
|
-
retainedChildren.delete(existing.child);
|
|
1659
|
-
existing.connection.close?.();
|
|
1660
|
-
teardownChild(existing.child);
|
|
1661
|
-
}
|
|
1769
|
+
if (decision.path === "new" && existing) abandonRetainedSession(existing);
|
|
1662
1770
|
|
|
1663
1771
|
// Claim the key for the whole turn — atomic with the checks above (no await
|
|
1664
1772
|
// in between), so a concurrent first turn for the same key sees it in flight
|
|
@@ -1666,15 +1774,77 @@ export function streamAcpTurn(
|
|
|
1666
1774
|
inFlightKeys.add(sessionKey);
|
|
1667
1775
|
try {
|
|
1668
1776
|
if (decision.path === "reuse" && existing) {
|
|
1669
|
-
await runReuseTurn(existing, ctxSigs, adapter);
|
|
1777
|
+
await runReuseTurn(existing, ctxSigs, adapter, thinkingValue);
|
|
1670
1778
|
} else {
|
|
1671
|
-
await runNewTurn(params, ctxSigs, engraving, config, adapter, nativeModelId);
|
|
1779
|
+
await runNewTurn(params, ctxSigs, engraving, config, adapter, nativeModelId, thinkingValue);
|
|
1672
1780
|
}
|
|
1673
1781
|
} finally {
|
|
1674
1782
|
inFlightKeys.delete(sessionKey);
|
|
1675
1783
|
}
|
|
1676
1784
|
});
|
|
1677
1785
|
|
|
1786
|
+
// --- #163: Pi's summary request → a one-shot, tool-less child that is never retained
|
|
1787
|
+
//
|
|
1788
|
+
// What makes it a SUMMARY turn rather than an ordinary new one, each for a reason:
|
|
1789
|
+
// - No tools, no MCP, nothing pre-approved, no skill plugins: the request declares no tools,
|
|
1790
|
+
// and a summarizer that could still run Bash would act where Pi cannot see it (Pi's own
|
|
1791
|
+
// "Summarization attempted to call a tool" guard reads `toolCall` blocks, which ACP tool
|
|
1792
|
+
// calls never become).
|
|
1793
|
+
// - No notice of any kind (`state.quiet`): Pi keeps `contentText(response.content)` as the
|
|
1794
|
+
// summary, joining every text block, and that summary lands in the session file.
|
|
1795
|
+
// - No first-user augment: it is operator context for a working session, not for a summary.
|
|
1796
|
+
// - The operator's engraving carrier stays (identity/billing/memory containment are not a
|
|
1797
|
+
// working-session feature), rendered with the empty MCP list this child really has.
|
|
1798
|
+
// - Pi's summarization instructions arrive as a system message, which the transcript
|
|
1799
|
+
// flattening never forwards (context.ts `case "system"`). They are relayed here, verbatim,
|
|
1800
|
+
// through Pi's own renderer, as the first prompt block — for this one turn only.
|
|
1801
|
+
// - Never retained and never registered: it shares no state with the conversation's session.
|
|
1802
|
+
// It neither reads nor tears down `bridgeSessions`, whatever key it arrived with.
|
|
1803
|
+
async function runSummaryTurn(
|
|
1804
|
+
config: ResolvedAcpConfig,
|
|
1805
|
+
adapter: AcpBackendAdapter,
|
|
1806
|
+
nativeModelId: string,
|
|
1807
|
+
): Promise<void> {
|
|
1808
|
+
state.quiet = true;
|
|
1809
|
+
summaryTurnActive = true;
|
|
1810
|
+
const summaryConfig: ResolvedAcpConfig = {
|
|
1811
|
+
...config,
|
|
1812
|
+
tools: [],
|
|
1813
|
+
mcpServers: [],
|
|
1814
|
+
skillPlugins: [],
|
|
1815
|
+
permissionAllow: [],
|
|
1816
|
+
// Filesystem isolation: no CLAUDE.md, hooks or plugins from the operator's Claude settings —
|
|
1817
|
+
// the summarizer gets the carrier and Pi's instructions, nothing else (config.ts:413-420).
|
|
1818
|
+
settingSources: [],
|
|
1819
|
+
};
|
|
1820
|
+
let engraving: string | null;
|
|
1821
|
+
let thinkingValue: string | undefined;
|
|
1822
|
+
try {
|
|
1823
|
+
engraving = adapter.loadCarrier({ mcpServerNames: [], config: summaryConfig });
|
|
1824
|
+
if (adapter.resolveThinking) {
|
|
1825
|
+
thinkingValue = adapter.resolveThinking({
|
|
1826
|
+
modelId: model.id,
|
|
1827
|
+
reasoning: opts?.reasoning,
|
|
1828
|
+
thinkingLevelMap: model.thinkingLevelMap,
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
} catch (err) {
|
|
1832
|
+
finishError(err, false);
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
const instructions = getCurrentSystemPrompt(context.messages).trim();
|
|
1836
|
+
const params: BootstrapParams = {
|
|
1837
|
+
cwd,
|
|
1838
|
+
modelId: model.id,
|
|
1839
|
+
bridgeConfigSignature: "",
|
|
1840
|
+
contextMessageSignatures: [],
|
|
1841
|
+
lifecyclePolicy: "turn-scoped",
|
|
1842
|
+
};
|
|
1843
|
+
await runNewTurn(params, [], engraving, summaryConfig, adapter, nativeModelId, thinkingValue, {
|
|
1844
|
+
instructions,
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1678
1848
|
// --- new session: spawn → initialize → newSession → setSessionConfigOption(model) → full transcript
|
|
1679
1849
|
async function runNewTurn(
|
|
1680
1850
|
params: BootstrapParams,
|
|
@@ -1683,6 +1853,9 @@ export function streamAcpTurn(
|
|
|
1683
1853
|
config: ResolvedAcpConfig,
|
|
1684
1854
|
adapter: AcpBackendAdapter,
|
|
1685
1855
|
nativeModelId: string,
|
|
1856
|
+
thinkingValue: string | undefined,
|
|
1857
|
+
/** #163: set only by runSummaryTurn — relay these instructions, add no augment. */
|
|
1858
|
+
summary?: { instructions: string },
|
|
1686
1859
|
): Promise<void> {
|
|
1687
1860
|
let child: AcpChildLike | undefined;
|
|
1688
1861
|
let session: BridgeSession | undefined;
|
|
@@ -1702,7 +1875,8 @@ export function streamAcpTurn(
|
|
|
1702
1875
|
// talking to ended. Announced once (read-and-clear); the turn continues
|
|
1703
1876
|
// normally, since opening a fresh child for a NEW user turn is not a
|
|
1704
1877
|
// replay of anything.
|
|
1705
|
-
|
|
1878
|
+
// A summary turn must not consume the conversation's end notice, whatever key it carries.
|
|
1879
|
+
const priorEnd = summary ? undefined : takeUnreportedChildEnd(sessionKey);
|
|
1706
1880
|
if (priorEnd) {
|
|
1707
1881
|
pushAcpLifecycleNotice(
|
|
1708
1882
|
state,
|
|
@@ -1845,6 +2019,18 @@ export function streamAcpTurn(
|
|
|
1845
2019
|
adapter.enforceModel({ connection, acpSessionId, modelId: model.id, nativeModelId }),
|
|
1846
2020
|
SET_MODEL_TIMEOUT_MS,
|
|
1847
2021
|
);
|
|
2022
|
+
// #142: the requested thinking level, set and read back before the prompt. The session keeps
|
|
2023
|
+
// the value the driver confirmed, so a reuse turn sets it again only when it changes.
|
|
2024
|
+
if (thinkingValue !== undefined && adapter.enforceThinking) {
|
|
2025
|
+
await withTimeout(
|
|
2026
|
+
"enforceThinking",
|
|
2027
|
+
adapter.enforceThinking({ connection, acpSessionId, modelId: model.id, value: thinkingValue }),
|
|
2028
|
+
SET_MODEL_TIMEOUT_MS,
|
|
2029
|
+
);
|
|
2030
|
+
session.thinkingValue = thinkingValue;
|
|
2031
|
+
// Visible like the model lock: the operator sees the effort the driver read back.
|
|
2032
|
+
pushAcpLifecycleNotice(state, `effort ${thinkingValue} read back`);
|
|
2033
|
+
}
|
|
1848
2034
|
|
|
1849
2035
|
// S2f visibility: the session is live and model-locked — the next gap is
|
|
1850
2036
|
// the prompt round-trip to the first token.
|
|
@@ -1859,12 +2045,15 @@ export function streamAcpTurn(
|
|
|
1859
2045
|
// the pi Context, so it stays out of contextMessageSignatures (NEXT §S2d
|
|
1860
2046
|
// gate ②). `new`-only → reuse turns stay clean (once-only). Entwurf-spawned
|
|
1861
2047
|
// prompts that already carry cwd/AGENTS.md get that one section de-duped.
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
2048
|
+
// #163: a summary turn carries Pi's summarization instructions instead.
|
|
2049
|
+
const prompt: AcpTextBlock[] = summary
|
|
2050
|
+
? [...(summary.instructions ? [{ type: "text" as const, text: summary.instructions }] : []), ...basePrompt]
|
|
2051
|
+
: prependNewPromptAugment(basePrompt, {
|
|
2052
|
+
backend: adapter.backend,
|
|
2053
|
+
cwd,
|
|
2054
|
+
mcpServerNames: mcpServerNames(config),
|
|
2055
|
+
emacsAgentSocket: process.env.PI_EMACS_AGENT_SOCKET?.trim() || undefined,
|
|
2056
|
+
});
|
|
1868
2057
|
|
|
1869
2058
|
// S2f visibility: about to send — say "sending" (not "sent") because the
|
|
1870
2059
|
// prompt could still sync-reject before the wire write; the next visible
|
|
@@ -1920,6 +2109,9 @@ export function streamAcpTurn(
|
|
|
1920
2109
|
unrefRetainedChild(spawned);
|
|
1921
2110
|
}
|
|
1922
2111
|
|
|
2112
|
+
if (signal?.aborted || mapPromptStopReason(promptResult?.stopReason).stopReason === "aborted") {
|
|
2113
|
+
pushAcpLifecycleNotice(state, retain ? ACP_CANCEL_KEPT_NOTICE : ACP_CANCEL_CLOSED_NOTICE);
|
|
2114
|
+
}
|
|
1923
2115
|
finishSuccess(adapter, session, promptResult);
|
|
1924
2116
|
|
|
1925
2117
|
if (retain) {
|
|
@@ -1947,6 +2139,7 @@ export function streamAcpTurn(
|
|
|
1947
2139
|
// own SIGTERM; the tail collected by then is sealed before our cleanup
|
|
1948
2140
|
// touches the stderr pipe.
|
|
1949
2141
|
const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
|
|
2142
|
+
if (aborted) pushAcpLifecycleNotice(state, ACP_CANCEL_CLOSED_NOTICE);
|
|
1950
2143
|
finishError(
|
|
1951
2144
|
err,
|
|
1952
2145
|
aborted,
|
|
@@ -1969,7 +2162,12 @@ export function streamAcpTurn(
|
|
|
1969
2162
|
}
|
|
1970
2163
|
|
|
1971
2164
|
// --- reuse: send only the latest user delta to the live ACP session
|
|
1972
|
-
async function runReuseTurn(
|
|
2165
|
+
async function runReuseTurn(
|
|
2166
|
+
session: BridgeSession,
|
|
2167
|
+
ctxSigs: string[],
|
|
2168
|
+
adapter: AcpBackendAdapter,
|
|
2169
|
+
thinkingValue: string | undefined,
|
|
2170
|
+
): Promise<void> {
|
|
1973
2171
|
/** Same phase discipline as a new turn — reuse just has no bootstrap to lose. */
|
|
1974
2172
|
let phase: "pre-prompt" | "prompt" = "pre-prompt";
|
|
1975
2173
|
try {
|
|
@@ -1979,7 +2177,27 @@ export function streamAcpTurn(
|
|
|
1979
2177
|
// resident turn looks identical to a cold start that stalled.
|
|
1980
2178
|
pushAcpLifecycleNotice(state, "reusing live session");
|
|
1981
2179
|
session.busy = true;
|
|
1982
|
-
|
|
2180
|
+
|
|
2181
|
+
// #142: a changed thinking level is set on THIS live session, before the delta is sent; the
|
|
2182
|
+
// conversation is kept. A refused or mismatched answer fails the turn before the prompt and
|
|
2183
|
+
// the session is dropped by the catch below, like any other pre-prompt failure on reuse.
|
|
2184
|
+
if (thinkingValue !== undefined && adapter.enforceThinking && session.thinkingValue !== thinkingValue) {
|
|
2185
|
+
await withTimeout(
|
|
2186
|
+
"enforceThinking",
|
|
2187
|
+
untilAborted(
|
|
2188
|
+
adapter.enforceThinking({
|
|
2189
|
+
connection: session.connection,
|
|
2190
|
+
acpSessionId: session.acpSessionId,
|
|
2191
|
+
modelId: model.id,
|
|
2192
|
+
value: thinkingValue,
|
|
2193
|
+
}),
|
|
2194
|
+
signal,
|
|
2195
|
+
),
|
|
2196
|
+
SET_MODEL_TIMEOUT_MS,
|
|
2197
|
+
);
|
|
2198
|
+
session.thinkingValue = thinkingValue;
|
|
2199
|
+
pushAcpLifecycleNotice(state, `effort ${thinkingValue} read back`);
|
|
2200
|
+
}
|
|
1983
2201
|
|
|
1984
2202
|
// The live ACP session already remembers the prior turns → send only the
|
|
1985
2203
|
// latest user delta (re-sending the transcript would duplicate history).
|
|
@@ -1993,6 +2211,12 @@ export function streamAcpTurn(
|
|
|
1993
2211
|
// ahead of the wire write; the prompt driver then owns the abort surface.
|
|
1994
2212
|
const wireParams = await applyProviderPayloadHook(options, { sessionId: session.acpSessionId, prompt }, model);
|
|
1995
2213
|
if (signal?.aborted) throw new Error("aborted during payload hook");
|
|
2214
|
+
// #142: this turn's stream receives updates only from the moment its own prompt goes out.
|
|
2215
|
+
// Before that, nothing on this live session can be this turn's output — a late update of
|
|
2216
|
+
// the previous (cancelled) turn arriving during the effort set would otherwise be shown as
|
|
2217
|
+
// this turn's answer. Inside the prompt window the wire carries no turn id; that part
|
|
2218
|
+
// rests on the agent not speaking for a turn it already answered.
|
|
2219
|
+
session.activePromptHandler = makePromptHandler(session);
|
|
1996
2220
|
phase = "prompt";
|
|
1997
2221
|
const promptResult = await awaitAcpPromptTurn(session, wireParams, {
|
|
1998
2222
|
signal,
|
|
@@ -2005,6 +2229,10 @@ export function streamAcpTurn(
|
|
|
2005
2229
|
// prefix-compat check sees the full prior history (GPT ④: store the
|
|
2006
2230
|
// ctxSigs from the START of this call, only after the turn succeeds).
|
|
2007
2231
|
session.contextMessageSignatures = ctxSigs;
|
|
2232
|
+
// A cancel the agent answered itself leaves this live session in place (no drop below).
|
|
2233
|
+
if (signal?.aborted || mapPromptStopReason(promptResult?.stopReason).stopReason === "aborted") {
|
|
2234
|
+
pushAcpLifecycleNotice(state, ACP_CANCEL_KEPT_NOTICE);
|
|
2235
|
+
}
|
|
2008
2236
|
finishSuccess(adapter, session, promptResult);
|
|
2009
2237
|
persistRecord(session, deps);
|
|
2010
2238
|
} catch (err) {
|
|
@@ -2018,6 +2246,7 @@ export function streamAcpTurn(
|
|
|
2018
2246
|
// ordering matters most here: teardown first would have overwritten the
|
|
2019
2247
|
// child's own exit status with our SIGTERM.
|
|
2020
2248
|
const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
|
|
2249
|
+
if (aborted) pushAcpLifecycleNotice(state, ACP_CANCEL_CLOSED_NOTICE);
|
|
2021
2250
|
// Without the session-scoped tail a mid-turn child death on a resident
|
|
2022
2251
|
// session surfaced as a bare "ACP connection closed" with nothing to read
|
|
2023
2252
|
// it by.
|
|
@@ -16,15 +16,10 @@
|
|
|
16
16
|
//
|
|
17
17
|
// The COMMON keys parsed here are Claude-SHAPED by origin (tools / permissionAllow /
|
|
18
18
|
// disallowedTools / settingSources / skillPlugins are claude-agent-acp's surface).
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// the backend-invariant exclude-tools preflight and folds every one of them into
|
|
24
|
-
// `bridgeConfigSignature` (so changing one still invalidates a reused cortex
|
|
25
|
-
// session). Backend-OWNED settings never land here: they ride the opaque
|
|
26
|
-
// `adapterSettings` seam (rail “Adapter contract”), which is what keeps a backend-named key like
|
|
27
|
-
// `cortexConnection` off this common shape.
|
|
19
|
+
// The common path reads `tools` for the backend-invariant exclude-tools preflight
|
|
20
|
+
// and folds these keys into `bridgeConfigSignature`. Backend-owned settings ride
|
|
21
|
+
// the opaque `adapterSettings` seam. A dormant `cortexConnection` does not select
|
|
22
|
+
// or enable Cortex ACP, which is disabled.
|
|
28
23
|
//
|
|
29
24
|
// PURITY / SIGNATURE contract (NEXT oracle C / 핀1 / GPT `…2f9325` boost):
|
|
30
25
|
// - `normalizeMcpServers` is pure: a SORTED, validated server list + a sha256
|
|
@@ -74,6 +74,19 @@ function textFromAssistantContent(content: AssistantMessage["content"]): string
|
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The harness fact pi already holds about an assistant turn that was cut
|
|
79
|
+
* (`stopReason: "aborted"` — an operator abort, or a vendor `cancelled` mapped to it; the line
|
|
80
|
+
* names no cause), stated so a NEW session's transcript does not read a
|
|
81
|
+
* cancelled turn as a clean refusal (#142: a fresh session concluded a command had
|
|
82
|
+
* not run when its first effect was on disk). It adds no claim about what did or
|
|
83
|
+
* did not happen — only that the turn was cut and that a cut is not a rollback.
|
|
84
|
+
* Only the full transcript carries it: a reused session already holds its own
|
|
85
|
+
* record of the interruption and receives just the latest user delta.
|
|
86
|
+
*/
|
|
87
|
+
export const ABORTED_TURN_STATUS =
|
|
88
|
+
"[turn status: aborted — actions this turn had started may already have taken effect; cancellation is not rollback]";
|
|
89
|
+
|
|
77
90
|
/** Render one pi message as a transcript line, or undefined to skip it. */
|
|
78
91
|
function renderMessage(message: Message): string | undefined {
|
|
79
92
|
switch (message.role) {
|
|
@@ -83,6 +96,10 @@ function renderMessage(message: Message): string | undefined {
|
|
|
83
96
|
}
|
|
84
97
|
case "assistant": {
|
|
85
98
|
const text = textFromAssistantContent(message.content).trim();
|
|
99
|
+
if (message.stopReason === "aborted") {
|
|
100
|
+
// Kept even when the cut turn left no text: the cut itself is the fact.
|
|
101
|
+
return text ? `Assistant: ${text}\n${ABORTED_TURN_STATUS}` : `Assistant: ${ABORTED_TURN_STATUS}`;
|
|
102
|
+
}
|
|
86
103
|
return text ? `Assistant: ${text}` : undefined;
|
|
87
104
|
}
|
|
88
105
|
case "toolResult": {
|