praxis-agent 0.55.0 → 0.55.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/README.md CHANGED
@@ -347,7 +347,7 @@ normal/low-capability full-frame p95 budgets of `<16.7/<33 ms`.
347
347
  `npm run test:coverage` measures all production code under `src/**` with V8 and
348
348
  enforces global floors of 79% statements, 70% branches, 85% functions, and 81% lines,
349
349
  and rejects any production runtime module with zero covered statements (while allowing
350
- type-only modules). `npm run test:fixtures` executes the 70-behavior native contract; 62 behaviors
350
+ type-only modules). `npm run test:fixtures` executes the 71-behavior native contract; 63 behaviors
351
351
  are qualified and 8 are explicitly excluded. `npm run verify:fixture-contracts`
352
352
  performs the structural check and is part of `npm run check`.
353
353
  `npm run test:core-completion` is retained as a compatibility alias for
@@ -1,5 +1,6 @@
1
1
  import { AgentRunCancelledError, } from '../core/runtime.js';
2
2
  import { contextRecoveryMadeProgress, isPromptTooLongError, } from '../core/context-budget.js';
3
+ import { StaleContextGenerationError } from './context-preparation.js';
3
4
  function assertSignal(signal) {
4
5
  if (signal?.aborted)
5
6
  throw new AgentRunCancelledError();
@@ -95,7 +96,9 @@ export class ContextEngine {
95
96
  return { kind: 'retry', envelope: proposal.envelope };
96
97
  }
97
98
  catch (cause) {
98
- if (signal?.aborted || cause instanceof AgentRunCancelledError)
99
+ if (signal?.aborted ||
100
+ cause instanceof AgentRunCancelledError ||
101
+ cause instanceof StaleContextGenerationError)
99
102
  throw cause;
100
103
  return { kind: 'exhausted', error };
101
104
  }
@@ -0,0 +1,60 @@
1
+ import { type ContextAssembler, type ContextAssemblyOptions } from '../core/context.js';
2
+ import type { ModelMessage, ModelToolDefinition } from '../core/runtime.js';
3
+ import type { ContextEnvelope } from './context-engine.js';
4
+ export interface ContextPreparationProjection {
5
+ readonly generation: number;
6
+ readonly envelope: ContextEnvelope;
7
+ readonly stableSystemMessageCount: number;
8
+ }
9
+ export interface ContextPreparationSources {
10
+ readonly history: () => readonly ModelMessage[];
11
+ readonly memory: () => readonly ModelMessage[];
12
+ readonly activeTools: () => readonly ModelToolDefinition[];
13
+ }
14
+ export interface ContextPreparationProjectOptions {
15
+ readonly includeHistory?: boolean;
16
+ readonly includeMemory?: boolean;
17
+ readonly pendingMessages?: readonly ModelMessage[];
18
+ }
19
+ export interface ContextHistoryReplacement {
20
+ readonly generation: number;
21
+ readonly envelope: ContextEnvelope;
22
+ readonly stableSystemMessageCount: number;
23
+ commit<T>(replace: () => Promise<T>): Promise<{
24
+ generation: number;
25
+ value: T;
26
+ }>;
27
+ }
28
+ export declare class StaleContextGenerationError extends Error {
29
+ readonly expectedGeneration: number;
30
+ readonly actualGeneration: number;
31
+ constructor(expectedGeneration: number, actualGeneration: number);
32
+ }
33
+ export interface ContextPreparationOptions {
34
+ readonly assembler?: ContextAssembler;
35
+ readonly sources: ContextPreparationSources;
36
+ readonly agentMentions?: () => {
37
+ readonly prompt: string;
38
+ readonly messages: readonly string[];
39
+ };
40
+ readonly initialGeneration?: number;
41
+ }
42
+ /** Owns provider-visible context projection and guarded history replacement. */
43
+ export declare class ContextPreparation {
44
+ private readonly assembler;
45
+ private readonly sources;
46
+ private readonly agentMentions;
47
+ private generation;
48
+ private prepared;
49
+ private replacementQueue;
50
+ constructor(options: ContextPreparationOptions);
51
+ refresh(options?: ContextAssemblyOptions): Promise<void>;
52
+ project(options?: ContextPreparationProjectOptions): ContextPreparationProjection;
53
+ proposeHistoryReplacement(input: {
54
+ readonly historyMessages: readonly ModelMessage[];
55
+ readonly pendingMessages?: readonly ModelMessage[];
56
+ }): ContextHistoryReplacement;
57
+ private projectWithMessages;
58
+ private decorate;
59
+ }
60
+ //# sourceMappingURL=context-preparation.d.ts.map
@@ -0,0 +1,139 @@
1
+ import { injectFirstUserMessageContext, projectContextSnapshot, } from '../core/context.js';
2
+ import { assembleContextSnapshot } from '../core/prompt-composer.js';
3
+ export class StaleContextGenerationError extends Error {
4
+ expectedGeneration;
5
+ actualGeneration;
6
+ constructor(expectedGeneration, actualGeneration) {
7
+ super(`Stale context generation: expected ${expectedGeneration}, actual ${actualGeneration}; prepare a new history replacement`);
8
+ this.name = 'StaleContextGenerationError';
9
+ this.expectedGeneration = expectedGeneration;
10
+ this.actualGeneration = actualGeneration;
11
+ }
12
+ }
13
+ function validateGeneration(generation) {
14
+ if (!Number.isSafeInteger(generation) || generation < 1) {
15
+ throw new TypeError('Context generation must be a positive safe integer');
16
+ }
17
+ }
18
+ function cloneTools(tools) {
19
+ return tools.map((tool) => ({ ...tool }));
20
+ }
21
+ /** Owns provider-visible context projection and guarded history replacement. */
22
+ export class ContextPreparation {
23
+ assembler;
24
+ sources;
25
+ agentMentions;
26
+ generation;
27
+ prepared;
28
+ replacementQueue = Promise.resolve();
29
+ constructor(options) {
30
+ this.assembler = options.assembler;
31
+ this.sources = options.sources;
32
+ this.agentMentions = options.agentMentions;
33
+ this.generation = options.initialGeneration ?? 1;
34
+ validateGeneration(this.generation);
35
+ }
36
+ async refresh(options = {}) {
37
+ const snapshot = await assembleContextSnapshot(this.assembler, options);
38
+ const projection = projectContextSnapshot(snapshot);
39
+ const stableCount = projection.stableSystemSectionCount;
40
+ const prepared = {
41
+ stableSystemMessages: projection.systemMessages.slice(0, stableCount),
42
+ volatileSystemMessages: projection.systemMessages.slice(stableCount),
43
+ ...(projection.firstUserMessageContext === undefined
44
+ ? {}
45
+ : { firstUserMessageContext: projection.firstUserMessageContext }),
46
+ stableSystemMessageCount: stableCount,
47
+ };
48
+ this.prepared = prepared;
49
+ }
50
+ project(options = {}) {
51
+ return this.projectWithMessages((options.includeHistory ?? true) ? this.sources.history() : [], (options.includeMemory ?? true) ? this.sources.memory() : [], options.pendingMessages ?? []);
52
+ }
53
+ proposeHistoryReplacement(input) {
54
+ const baseGeneration = this.generation;
55
+ if (baseGeneration === Number.MAX_SAFE_INTEGER) {
56
+ throw new RangeError('Context generation cannot exceed Number.MAX_SAFE_INTEGER');
57
+ }
58
+ const generation = baseGeneration + 1;
59
+ const projection = this.projectWithMessages(input.historyMessages, [], input.pendingMessages ?? []);
60
+ let committed = false;
61
+ return {
62
+ generation,
63
+ envelope: projection.envelope,
64
+ stableSystemMessageCount: projection.stableSystemMessageCount,
65
+ commit: async (replace) => {
66
+ const operation = this.replacementQueue.then(async () => {
67
+ if (this.generation !== baseGeneration) {
68
+ throw new StaleContextGenerationError(baseGeneration, this.generation);
69
+ }
70
+ if (committed) {
71
+ throw new StaleContextGenerationError(baseGeneration, this.generation);
72
+ }
73
+ const value = await replace();
74
+ this.generation = generation;
75
+ committed = true;
76
+ return { generation, value };
77
+ });
78
+ this.replacementQueue = operation.then(() => undefined, () => undefined);
79
+ return operation;
80
+ },
81
+ };
82
+ }
83
+ projectWithMessages(historyMessages, memoryMessages, pendingMessages) {
84
+ const prepared = this.prepared;
85
+ if (!prepared) {
86
+ throw new Error('ContextPreparation must be refreshed before projecting');
87
+ }
88
+ const history = [...historyMessages];
89
+ const memory = [...memoryMessages];
90
+ const pending = [...pendingMessages];
91
+ const decoratedHistory = this.decorate([...history, ...memory, ...pending]);
92
+ const messages = [
93
+ ...prepared.stableSystemMessages,
94
+ ...prepared.volatileSystemMessages,
95
+ ...decoratedHistory,
96
+ ];
97
+ return {
98
+ generation: this.generation,
99
+ envelope: {
100
+ messages,
101
+ tools: cloneTools(this.sources.activeTools()),
102
+ },
103
+ stableSystemMessageCount: prepared.stableSystemMessageCount,
104
+ };
105
+ }
106
+ decorate(messages) {
107
+ const prepared = this.prepared;
108
+ if (!prepared) {
109
+ throw new Error('ContextPreparation must be refreshed before projecting');
110
+ }
111
+ const withFirstUserContext = injectFirstUserMessageContext(messages, prepared.firstUserMessageContext);
112
+ const mentionInput = this.agentMentions?.();
113
+ if (!mentionInput || mentionInput.messages.length === 0)
114
+ return withFirstUserContext;
115
+ let insertionIndex = withFirstUserContext.length;
116
+ let foundPrompt = false;
117
+ for (let index = withFirstUserContext.length - 1; index >= 0; index -= 1) {
118
+ const message = withFirstUserContext[index];
119
+ if (message?.role === 'user' &&
120
+ typeof message.content === 'string' &&
121
+ message.content.endsWith(mentionInput.prompt)) {
122
+ insertionIndex = index;
123
+ foundPrompt = true;
124
+ break;
125
+ }
126
+ }
127
+ if (!foundPrompt)
128
+ return withFirstUserContext;
129
+ return [
130
+ ...withFirstUserContext.slice(0, insertionIndex),
131
+ ...mentionInput.messages.map((content) => ({
132
+ role: 'user',
133
+ content,
134
+ })),
135
+ ...withFirstUserContext.slice(insertionIndex),
136
+ ];
137
+ }
138
+ }
139
+ //# sourceMappingURL=context-preparation.js.map
@@ -236,7 +236,7 @@ export declare class ClaudeSessionService {
236
236
  private readonly hookLifecycle;
237
237
  private readonly leadOperations;
238
238
  private readonly fileChangeWatcher;
239
- private readonly activeTurnInputs;
239
+ private readonly turnCoordinator;
240
240
  private runtimeCwd;
241
241
  constructor(options: ClaudeSessionServiceOptions);
242
242
  nextScheduledPrompt(signal?: AbortSignal): Promise<ScheduledPrompt | null>;
@@ -21,9 +21,9 @@ import { BackgroundTaskRuntime, } from './background-task-runtime.js';
21
21
  import { backgroundAgentNotificationMarkers, } from './background-agent-manager.js';
22
22
  import { usageCostUsd } from '../core/usage.js';
23
23
  import { isSessionId } from '../core/session.js';
24
- import { ActiveTurnInputMailbox, } from '../core/active-turn-input.js';
25
24
  import { ContextBudget, estimateModelRequestTokens, isPromptTooLongError, } from '../core/context-budget.js';
26
25
  import { ContextEngine } from './context-engine.js';
26
+ import { ContextPreparation } from './context-preparation.js';
27
27
  import { TurnMemoryCoordinator } from './turn-memory-coordinator.js';
28
28
  import { injectFirstUserMessageContext, projectContextSnapshot, } from '../core/context.js';
29
29
  import { assembleContextSnapshot } from '../core/prompt-composer.js';
@@ -38,7 +38,7 @@ import { SubagentLifecycleStore } from '../persistence/subagent-lifecycle-store.
38
38
  import { ModelCompactor } from './model-compactor.js';
39
39
  import { agentMemoryPrompt, ClaudeSubagentExecutor, StructuredOutputRegistry, } from './subagent-service.js';
40
40
  import { ScheduledPromptManager, } from './scheduled-prompt-manager.js';
41
- import { TurnTerminalController } from './turn-lifecycle.js';
41
+ import { TurnCoordinator } from './turn-lifecycle.js';
42
42
  import { ClaudeScheduledToolRegistry } from '../tools/claude-scheduled-tools.js';
43
43
  import { ClaudeTaskToolRegistry } from '../tools/claude-task-tools.js';
44
44
  import { ClaudeWorkflowToolRegistry } from '../tools/claude-workflow-tools.js';
@@ -684,12 +684,16 @@ export class ClaudeSessionService {
684
684
  hookLifecycle;
685
685
  leadOperations;
686
686
  fileChangeWatcher;
687
- activeTurnInputs = new Map();
687
+ turnCoordinator;
688
688
  runtimeCwd;
689
689
  constructor(options) {
690
690
  const dataPlane = options.dataPlane ?? 'native';
691
691
  assertNativeDataPlane(dataPlane);
692
692
  this.options = { ...options, dataPlane };
693
+ this.turnCoordinator = new TurnCoordinator({
694
+ eventSink: options.eventSink ?? (() => undefined),
695
+ createSteeringId: randomUUID,
696
+ });
693
697
  this.assertNativeTranscriptOptions();
694
698
  this.leadOperations = options.teamLeadOperations ?? null;
695
699
  this.hookLifecycle = new HookLifecycle(options.hooks, options.eventSink);
@@ -899,18 +903,7 @@ export class ClaudeSessionService {
899
903
  }
900
904
  async close() {
901
905
  this.closing = true;
902
- for (const { mailbox } of this.activeTurnInputs.values()) {
903
- if (!mailbox)
904
- continue;
905
- for (const item of mailbox.close()) {
906
- this.options.eventSink?.({
907
- type: 'user-input-rejected',
908
- id: item.id,
909
- content: item.content,
910
- reason: 'closed',
911
- });
912
- }
913
- }
906
+ this.turnCoordinator.close();
914
907
  await this.fileChangeWatcher?.close(5_000);
915
908
  await this.hookLifecycle.close();
916
909
  await this.drainDetachedHookRuns(5_000);
@@ -1330,28 +1323,10 @@ export class ClaudeSessionService {
1330
1323
  });
1331
1324
  }
1332
1325
  steer(sessionId, content) {
1333
- const active = this.activeTurnInputs.get(sessionId);
1334
- if (!active)
1335
- return { kind: 'no-active-turn' };
1336
- const mailbox = active.mailbox;
1337
- if (!mailbox)
1338
- return { kind: 'not-steerable' };
1339
- const result = mailbox.enqueue(content);
1340
- if (result.kind === 'accepted')
1341
- return result;
1342
- if (result.kind === 'empty')
1343
- return result;
1344
- return { kind: 'turn-completing' };
1326
+ return this.turnCoordinator.steer(sessionId, content);
1345
1327
  }
1346
1328
  withdrawSteering(sessionId, id) {
1347
- const active = this.activeTurnInputs.get(sessionId);
1348
- if (!active)
1349
- return { kind: 'no-active-turn' };
1350
- const mailbox = active.mailbox;
1351
- if (!mailbox)
1352
- return { kind: 'not-steerable' };
1353
- const result = mailbox.withdraw(id);
1354
- return result.kind === 'withdrawn' ? result : { kind: 'not-pending' };
1329
+ return this.turnCoordinator.withdrawSteering(sessionId, id);
1355
1330
  }
1356
1331
  async resumeShell(sessionId, command, signal, name, resumeSessionAt) {
1357
1332
  this.worktreeManager?.bindSession(sessionId);
@@ -2535,29 +2510,8 @@ export class ClaudeSessionService {
2535
2510
  const documents = submission.kind === 'prompt' ? (submission.documents ?? []) : [];
2536
2511
  const shellCommand = submission.kind === 'shell' ? submission.command : undefined;
2537
2512
  const skipUserPrompt = submission.kind === 'retry';
2538
- const controller = new TurnTerminalController(this.options.eventSink ?? (() => undefined));
2539
- let activeTurnInput;
2540
- let activeTurnRecord;
2541
- try {
2513
+ return this.turnCoordinator.run(request, async ({ emit, steering }) => {
2542
2514
  this.assertTurnWritable();
2543
- if (prompt.length === 0 && images.length === 0 && documents.length === 0)
2544
- throw new Error('Prompt must not be empty');
2545
- if (name !== undefined && name.length === 0) {
2546
- throw new Error('Session name must not be empty');
2547
- }
2548
- if (shellCommand !== undefined && shellCommand.trim().length === 0) {
2549
- throw new Error('Shell command must not be empty');
2550
- }
2551
- if (this.activeTurnInputs.has(sessionId)) {
2552
- throw new Error(`conflict: locked (session ${sessionId} already has an active turn)`);
2553
- }
2554
- activeTurnRecord =
2555
- shellCommand === undefined
2556
- ? {
2557
- mailbox: (activeTurnInput = new ActiveTurnInputMailbox(randomUUID)),
2558
- }
2559
- : {};
2560
- this.activeTurnInputs.set(sessionId, activeTurnRecord);
2561
2515
  await this.activateSessionCostTracker(sessionId);
2562
2516
  await this.ensureFileResources(sessionId, signal);
2563
2517
  this.worktreeManager?.bindSession(sessionId);
@@ -3071,7 +3025,7 @@ export class ClaudeSessionService {
3071
3025
  deferPreToolUseOutcome: (call) => pendingRecoveryToolCallIds.has(call.id),
3072
3026
  })
3073
3027
  : null;
3074
- const runtime = new AgentRuntime(provider, controller.emit, {
3028
+ const runtime = new AgentRuntime(provider, emit, {
3075
3029
  emitInitialContextState: false,
3076
3030
  ...(this.options.emitToolUseSummaries
3077
3031
  ? {
@@ -3413,19 +3367,30 @@ export class ClaudeSessionService {
3413
3367
  let agentSystem = null;
3414
3368
  let planModeMessage;
3415
3369
  let sessionMemoryMessage = null;
3416
- let contextMessages = [];
3417
- let contextProjection = {
3418
- systemMessages: [],
3419
- stableSystemSectionCount: 0,
3420
- };
3421
- let stableSystemMessageCount = 0;
3370
+ let agentMentionMessages = [];
3371
+ const contextPreparation = new ContextPreparation({
3372
+ ...(this.options.contextAssembler
3373
+ ? { assembler: this.options.contextAssembler }
3374
+ : {}),
3375
+ sources: {
3376
+ history: activeTurnMessages,
3377
+ memory: () => projectMemoryRecallMessages,
3378
+ activeTools: () => provider.capabilities.tools
3379
+ ? (activeTurnTools?.definitions() ?? [])
3380
+ : [],
3381
+ },
3382
+ agentMentions: () => ({
3383
+ prompt: effectivePrompt,
3384
+ messages: agentMentionMessages,
3385
+ }),
3386
+ });
3422
3387
  refreshRuntimeContext = async () => {
3423
3388
  agentSystem = await this.mainAgentSystemPrompt(agent);
3424
3389
  planModeMessage =
3425
3390
  this.options.interactiveTools?.contextMessage(sessionId);
3426
3391
  // DEBUG_PLAN_CONTEXT
3427
3392
  sessionMemoryMessage = this.sessionMemoryMessage(await turnMemory.sessionSummary());
3428
- const assembledContext = await assembleContextSnapshot(this.options.contextAssembler, {
3393
+ await contextPreparation.refresh({
3429
3394
  cwd: this.activeCwd(),
3430
3395
  lifecycleId: sessionId,
3431
3396
  ...(agentSystem
@@ -3442,10 +3407,6 @@ export class ClaudeSessionService {
3442
3407
  : {}),
3443
3408
  },
3444
3409
  });
3445
- contextProjection = projectContextSnapshot(assembledContext);
3446
- stableSystemMessageCount =
3447
- contextProjection.stableSystemSectionCount;
3448
- contextMessages = [...contextProjection.systemMessages];
3449
3410
  };
3450
3411
  await refreshRuntimeContext();
3451
3412
  const expansion = shouldSkipUserPrompt()
@@ -3475,9 +3436,6 @@ export class ClaudeSessionService {
3475
3436
  let compactionDurationMs;
3476
3437
  let compactionDurationWithoutRetriesMs;
3477
3438
  let compactionModelUsage;
3478
- const currentDefinitions = () => provider.capabilities.tools
3479
- ? (activeTurnTools?.definitions() ?? [])
3480
- : [];
3481
3439
  const budget = this.contextBudget(provider);
3482
3440
  const contextEngine = new ContextEngine({
3483
3441
  ...(budget ? { budget } : {}),
@@ -3509,88 +3467,50 @@ export class ClaudeSessionService {
3509
3467
  ? { documents }
3510
3468
  : {}),
3511
3469
  }));
3512
- const agentMentionMessages = shellCommand === undefined && !shouldSkipUserPrompt()
3513
- ? (this.options.extensions?.agentMentionMessages(effectivePrompt) ?? [])
3514
- : [];
3515
- const injectAgentMentionContext = (messages) => {
3516
- if (agentMentionMessages.length === 0)
3517
- return [...messages];
3518
- let insertionIndex = messages.length;
3519
- let foundPrompt = false;
3520
- for (let index = messages.length - 1; index >= 0; index -= 1) {
3521
- const message = messages[index];
3522
- if (message?.role === 'user' &&
3523
- typeof message.content === 'string' &&
3524
- message.content.endsWith(effectivePrompt)) {
3525
- insertionIndex = index;
3526
- foundPrompt = true;
3527
- break;
3528
- }
3529
- }
3530
- if (!foundPrompt)
3531
- return [...messages];
3532
- return [
3533
- ...messages.slice(0, insertionIndex),
3534
- ...agentMentionMessages.map((content) => ({
3535
- role: 'user',
3536
- content,
3537
- })),
3538
- ...messages.slice(insertionIndex),
3539
- ];
3540
- };
3541
- const injectDynamicContext = (messages) => injectFirstUserMessageContext(messages, contextProjection.firstUserMessageContext);
3542
- const injectTurnContext = (messages) => injectAgentMentionContext(injectDynamicContext(messages));
3470
+ agentMentionMessages =
3471
+ shellCommand === undefined && !shouldSkipUserPrompt()
3472
+ ? (this.options.extensions?.agentMentionMessages(effectivePrompt) ?? [])
3473
+ : [];
3543
3474
  let compactionAnchorUuid = this.lastMessageUuid(snapshot.entries);
3544
3475
  const contextTransitionPort = (pendingMessages = [], preservedUserMessages = []) => ({
3545
3476
  current: () => {
3546
- const historyMessages = [
3547
- ...activeTurnMessages(),
3548
- ...projectMemoryRecallMessages,
3549
- ];
3550
- return {
3551
- messages: [
3552
- ...contextMessages,
3553
- ...injectTurnContext([
3554
- ...historyMessages,
3555
- ...pendingMessages,
3556
- ]),
3557
- ],
3558
- tools: currentDefinitions(),
3559
- };
3477
+ const projection = contextPreparation.project({
3478
+ pendingMessages,
3479
+ });
3480
+ return projection.envelope;
3560
3481
  },
3561
- irreducible: () => ({
3562
- messages: [
3563
- ...contextMessages,
3564
- ...injectTurnContext([
3565
- ...pendingMessages,
3566
- ...preservedUserMessages.map((content) => ({
3567
- role: 'user',
3568
- content,
3569
- })),
3570
- ]),
3482
+ irreducible: () => contextPreparation.project({
3483
+ includeHistory: false,
3484
+ includeMemory: false,
3485
+ pendingMessages: [
3486
+ ...pendingMessages,
3487
+ ...preservedUserMessages.map((content) => ({
3488
+ role: 'user',
3489
+ content,
3490
+ })),
3571
3491
  ],
3572
- tools: currentDefinitions(),
3573
- }),
3492
+ }).envelope,
3574
3493
  propose: async () => {
3575
3494
  const activeNativeLease = nativeLease;
3576
3495
  if (!budget)
3577
3496
  throw new Error('Context budget is unavailable');
3578
- const definitions = currentDefinitions();
3497
+ const definitions = contextPreparation.project().envelope.tools;
3579
3498
  const historyMessages = activeTurnMessages();
3580
3499
  if (historyMessages.length === 0)
3581
3500
  throw new Error('Cannot compact an empty native transcript');
3582
3501
  if (unresolvedActiveToolCallIds(historyMessages).length > 0)
3583
3502
  throw new Error('Cannot compact a native transcript with unresolved tool calls');
3584
- const irreducibleMessages = [
3585
- ...contextMessages,
3586
- ...injectTurnContext([
3503
+ const irreducibleMessages = contextPreparation.project({
3504
+ includeHistory: false,
3505
+ includeMemory: false,
3506
+ pendingMessages: [
3587
3507
  ...pendingMessages,
3588
3508
  ...preservedUserMessages.map((content) => ({
3589
3509
  role: 'user',
3590
3510
  content,
3591
3511
  })),
3592
- ]),
3593
- ];
3512
+ ],
3513
+ }).envelope.messages;
3594
3514
  let compactableMessages = historyMessages;
3595
3515
  let preservedMessages = [];
3596
3516
  let compactionLogicalParentId;
@@ -3757,14 +3677,15 @@ export class ClaudeSessionService {
3757
3677
  };
3758
3678
  let replayMessages = preservedMessages;
3759
3679
  try {
3760
- const replayReport = budget.evaluate([
3761
- ...contextMessages,
3762
- ...injectTurnContext([
3680
+ const replayReport = budget.evaluate(contextPreparation.project({
3681
+ includeHistory: false,
3682
+ includeMemory: false,
3683
+ pendingMessages: [
3763
3684
  summaryMessage,
3764
3685
  ...replayMessages,
3765
3686
  ...pendingMessages,
3766
- ]),
3767
- ], definitions);
3687
+ ],
3688
+ }).envelope.messages, definitions);
3768
3689
  if (replayReport.shouldCompact)
3769
3690
  throw new Error('replay overflow');
3770
3691
  }
@@ -3775,23 +3696,16 @@ export class ClaudeSessionService {
3775
3696
  // contains its contents and user prompts remain available.
3776
3697
  replayMessages = replayMessages.filter((message) => message.role === 'user' && !Array.isArray(message.content));
3777
3698
  }
3778
- const proposedMessages = [
3779
- ...contextMessages,
3780
- ...injectTurnContext([
3781
- summaryMessage,
3782
- ...replayMessages,
3783
- ...pendingMessages,
3784
- ]),
3785
- ];
3699
+ const replacement = contextPreparation.proposeHistoryReplacement({
3700
+ historyMessages: [summaryMessage, ...replayMessages],
3701
+ pendingMessages,
3702
+ });
3786
3703
  return {
3787
- envelope: {
3788
- messages: proposedMessages,
3789
- tools: definitions,
3790
- },
3704
+ envelope: replacement.envelope,
3791
3705
  commit: async () => {
3792
3706
  if (signal?.aborted)
3793
3707
  throw new AgentRunCancelledError();
3794
- const ids = await nativeLease.appendCompaction({
3708
+ const committed = await replacement.commit(() => nativeLease.appendCompaction({
3795
3709
  summary: compacted.summary,
3796
3710
  trigger: 'auto',
3797
3711
  preTokens,
@@ -3808,7 +3722,8 @@ export class ClaudeSessionService {
3808
3722
  preservePrefix: false,
3809
3723
  }
3810
3724
  : {}),
3811
- });
3725
+ }));
3726
+ const ids = committed.value;
3812
3727
  await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'auto', compact_summary: compacted.summary }, 'auto', signal);
3813
3728
  if (this.options.hooks) {
3814
3729
  const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
@@ -4095,25 +4010,24 @@ export class ClaudeSessionService {
4095
4010
  };
4096
4011
  }
4097
4012
  if (shellCommand === undefined && budget) {
4098
- const definitions = currentDefinitions();
4099
4013
  await contextEngine.prepare(contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4100
- budget.assertFits(budget.evaluate([
4101
- ...contextMessages,
4102
- ...injectTurnContext(activeTurnMessages()),
4103
- ], definitions));
4014
+ const projection = contextPreparation.project({
4015
+ includeMemory: false,
4016
+ });
4017
+ budget.assertFits(budget.evaluate(projection.envelope.messages, projection.envelope.tools));
4104
4018
  }
4105
4019
  let stopHookActive = false;
4020
+ const initialProjection = contextPreparation.project({
4021
+ includeMemory: false,
4022
+ });
4106
4023
  const runtimeRequest = {
4107
4024
  sessionId,
4108
- messages: [
4109
- ...contextMessages,
4110
- ...injectTurnContext(activeTurnMessages()),
4111
- ],
4112
- stableSystemMessageCount,
4025
+ messages: initialProjection.envelope.messages,
4026
+ stableSystemMessageCount: initialProjection.stableSystemMessageCount,
4113
4027
  cwd: this.activeCwd(),
4114
4028
  toolResultDirectory,
4115
4029
  observer,
4116
- ...(activeTurnInput ? { steering: activeTurnInput } : {}),
4030
+ ...(steering ? { steering } : {}),
4117
4031
  ...(this.options.effort ? { effort: this.options.effort } : {}),
4118
4032
  ...(this.options.maxModelTurns !== undefined
4119
4033
  ? { maxModelTurns: this.options.maxModelTurns }
@@ -4138,14 +4052,10 @@ export class ClaudeSessionService {
4138
4052
  if (shellCommand === undefined) {
4139
4053
  await contextEngine.prepare(contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4140
4054
  }
4141
- runtimeRequest.stableSystemMessageCount = stableSystemMessageCount;
4142
- return [
4143
- ...contextMessages,
4144
- ...injectTurnContext([
4145
- ...activeTurnMessages(),
4146
- ...projectMemoryRecallMessages,
4147
- ]),
4148
- ];
4055
+ const projection = contextPreparation.project();
4056
+ runtimeRequest.stableSystemMessageCount =
4057
+ projection.stableSystemMessageCount;
4058
+ return projection.envelope.messages;
4149
4059
  },
4150
4060
  ...(this.options.hooks ||
4151
4061
  subagentExecutor ||
@@ -4274,14 +4184,10 @@ export class ClaudeSessionService {
4274
4184
  }
4275
4185
  // The single reactive retry must use the compacted transcript, not
4276
4186
  // the stale request copy captured before the compact boundary.
4277
- runtimeRequest.messages = [
4278
- ...contextMessages,
4279
- ...injectTurnContext([
4280
- ...activeTurnMessages(),
4281
- ...projectMemoryRecallMessages,
4282
- ]),
4283
- ];
4284
- runtimeRequest.stableSystemMessageCount = stableSystemMessageCount;
4187
+ const projection = contextPreparation.project();
4188
+ runtimeRequest.messages = projection.envelope.messages;
4189
+ runtimeRequest.stableSystemMessageCount =
4190
+ projection.stableSystemMessageCount;
4285
4191
  runtimeRequest.deferFailureKinds = true;
4286
4192
  try {
4287
4193
  result = await attemptMainTurn();
@@ -4417,11 +4323,11 @@ export class ClaudeSessionService {
4417
4323
  ? projectNativeSessionEntries(nativeLease.activeEvents()).at(-1)
4418
4324
  ?.uuid
4419
4325
  : undefined;
4420
- const providerVisibleMessages = [
4421
- ...contextMessages,
4422
- ...injectTurnContext(memorySnapshot),
4423
- ];
4424
- const definitions = currentDefinitions();
4326
+ const providerProjection = contextPreparation.project({
4327
+ includeMemory: false,
4328
+ });
4329
+ const providerVisibleMessages = providerProjection.envelope.messages;
4330
+ const definitions = providerProjection.envelope.tools;
4425
4331
  const currentContextTokens = contextEngine.report({
4426
4332
  messages: providerVisibleMessages,
4427
4333
  tools: definitions,
@@ -4507,27 +4413,8 @@ export class ClaudeSessionService {
4507
4413
  return scratchResult.value;
4508
4414
  });
4509
4415
  }
4510
- controller.complete();
4511
4416
  return result;
4512
- }
4513
- catch (error) {
4514
- controller.fail(error, signal);
4515
- throw error;
4516
- }
4517
- finally {
4518
- if (activeTurnInput !== undefined) {
4519
- for (const item of activeTurnInput.close()) {
4520
- this.options.eventSink?.({
4521
- type: 'user-input-rejected',
4522
- id: item.id,
4523
- content: item.content,
4524
- reason: signal?.aborted ? 'cancelled' : 'failed',
4525
- });
4526
- }
4527
- }
4528
- if (this.activeTurnInputs.get(sessionId) === activeTurnRecord)
4529
- this.activeTurnInputs.delete(sessionId);
4530
- }
4417
+ });
4531
4418
  }
4532
4419
  async ensureFileResources(sessionId, signal) {
4533
4420
  const resources = this.options.fileResources ?? [];
@@ -1,6 +1,5 @@
1
1
  import { type ModelDocument, type ModelImage, type RuntimeEventSink } from '../core/runtime.js';
2
- import type { LifecycleState } from '../core/agent-orchestration.js';
3
- export type TurnTerminalState = Extract<LifecycleState, 'completed' | 'failed' | 'cancelled'>;
2
+ import { type ActiveTurnInputCommandResult, type ActiveTurnInputPort } from '../core/active-turn-input.js';
4
3
  export type TurnActivation = {
5
4
  kind: 'start';
6
5
  sessionId: string;
@@ -28,13 +27,26 @@ export interface TurnRequest {
28
27
  submission: TurnSubmission;
29
28
  signal?: AbortSignal;
30
29
  }
31
- export declare class TurnTerminalController {
32
- private readonly sink;
33
- private terminal;
34
- constructor(sink: RuntimeEventSink);
35
- emit: RuntimeEventSink;
36
- complete(): void;
37
- fail(error: unknown, signal?: AbortSignal): void;
30
+ export interface TurnScope {
31
+ readonly emit: RuntimeEventSink;
32
+ readonly steering?: ActiveTurnInputPort;
33
+ }
34
+ export interface TurnCoordinatorOptions {
35
+ readonly eventSink: RuntimeEventSink;
36
+ readonly createSteeringId: () => string;
37
+ }
38
+ /** Owns the lifecycle and active-turn coordination for one session service. */
39
+ export declare class TurnCoordinator {
40
+ private readonly options;
41
+ private readonly activeTurns;
42
+ constructor(options: TurnCoordinatorOptions);
43
+ run<T>(request: TurnRequest, work: (scope: TurnScope) => Promise<T>): Promise<T>;
44
+ steer(sessionId: string, content: string): ActiveTurnInputCommandResult;
45
+ withdrawSteering(sessionId: string, id: string): ActiveTurnInputCommandResult;
46
+ close(): void;
47
+ private validateRequest;
48
+ private terminalState;
38
49
  private transition;
50
+ private rejectPending;
39
51
  }
40
52
  //# sourceMappingURL=turn-lifecycle.d.ts.map
@@ -1,36 +1,150 @@
1
1
  import { AgentRunCancelledError, ModelProviderError, } from '../core/runtime.js';
2
- export class TurnTerminalController {
3
- sink;
4
- terminal = false;
5
- constructor(sink) {
6
- this.sink = sink;
7
- }
8
- emit = (event) => {
9
- if (event.type === 'state' &&
10
- (event.state === 'completed' ||
11
- event.state === 'cancelled' ||
12
- event.state === 'failed')) {
13
- return;
2
+ import { ActiveTurnInputMailbox, } from '../core/active-turn-input.js';
3
+ /** Owns the lifecycle and active-turn coordination for one session service. */
4
+ export class TurnCoordinator {
5
+ options;
6
+ activeTurns = new Map();
7
+ constructor(options) {
8
+ this.options = options;
9
+ }
10
+ async run(request, work) {
11
+ const { sessionId } = request.activation;
12
+ const mailbox = request.submission.kind === 'shell'
13
+ ? undefined
14
+ : new ActiveTurnInputMailbox(this.options.createSteeringId);
15
+ const record = {
16
+ ...(mailbox ? { mailbox } : {}),
17
+ terminal: false,
18
+ };
19
+ let terminalState = 'failed';
20
+ let pendingFailure;
21
+ const scope = {
22
+ emit: (event) => {
23
+ if (event.type === 'state' &&
24
+ (event.state === 'completed' ||
25
+ event.state === 'failed' ||
26
+ event.state === 'cancelled')) {
27
+ return;
28
+ }
29
+ this.options.eventSink(event);
30
+ },
31
+ ...(mailbox ? { steering: mailbox } : {}),
32
+ };
33
+ try {
34
+ this.validateRequest(request);
35
+ if (this.activeTurns.has(sessionId)) {
36
+ throw new Error(`conflict: locked (session ${sessionId} already has an active turn)`);
37
+ }
38
+ this.activeTurns.set(sessionId, record);
39
+ const result = await work(scope);
40
+ terminalState = 'completed';
41
+ this.transition(record, 'completed');
42
+ return result;
43
+ }
44
+ catch (error) {
45
+ if (!record.terminal) {
46
+ terminalState = this.terminalState(error, request.signal);
47
+ this.transition(record, terminalState);
48
+ }
49
+ throw error;
50
+ }
51
+ finally {
52
+ try {
53
+ if (mailbox) {
54
+ pendingFailure = this.rejectPending(mailbox.close(), terminalState === 'cancelled' ? 'cancelled' : 'failed');
55
+ }
56
+ }
57
+ finally {
58
+ if (this.activeTurns.get(sessionId) === record) {
59
+ this.activeTurns.delete(sessionId);
60
+ }
61
+ }
62
+ if (pendingFailure) {
63
+ // A rejected-input sink failure intentionally retains its prior precedence.
64
+ // eslint-disable-next-line no-unsafe-finally -- compatibility is covered by the sink-error regression
65
+ throw pendingFailure.error;
66
+ }
14
67
  }
15
- this.sink(event);
16
- };
17
- complete() {
18
- this.transition('completed');
19
68
  }
20
- fail(error, signal) {
21
- if (this.terminal)
22
- return;
23
- const cancelled = signal?.aborted === true ||
69
+ steer(sessionId, content) {
70
+ const active = this.activeTurns.get(sessionId);
71
+ if (!active)
72
+ return { kind: 'no-active-turn' };
73
+ if (!active.mailbox)
74
+ return { kind: 'not-steerable' };
75
+ const result = active.mailbox.enqueue(content);
76
+ if (result.kind === 'accepted' || result.kind === 'empty')
77
+ return result;
78
+ return { kind: 'turn-completing' };
79
+ }
80
+ withdrawSteering(sessionId, id) {
81
+ const active = this.activeTurns.get(sessionId);
82
+ if (!active)
83
+ return { kind: 'no-active-turn' };
84
+ if (!active.mailbox)
85
+ return { kind: 'not-steerable' };
86
+ const result = active.mailbox.withdraw(id);
87
+ return result.kind === 'withdrawn' ? result : { kind: 'not-pending' };
88
+ }
89
+ close() {
90
+ let firstFailure;
91
+ for (const active of this.activeTurns.values()) {
92
+ if (!active.mailbox)
93
+ continue;
94
+ const failure = this.rejectPending(active.mailbox.close(), 'closed');
95
+ firstFailure ??= failure;
96
+ }
97
+ if (firstFailure)
98
+ throw firstFailure.error;
99
+ }
100
+ validateRequest(request) {
101
+ const { activation, submission } = request;
102
+ const prompt = submission.kind === 'shell'
103
+ ? `! ${submission.command}`
104
+ : submission.kind === 'retry'
105
+ ? submission.prompt
106
+ : submission.text;
107
+ const images = submission.kind === 'prompt' ? (submission.images ?? []) : [];
108
+ const documents = submission.kind === 'prompt' ? (submission.documents ?? []) : [];
109
+ if (prompt.length === 0 && images.length === 0 && documents.length === 0) {
110
+ throw new Error('Prompt must not be empty');
111
+ }
112
+ if (activation.name !== undefined && activation.name.length === 0) {
113
+ throw new Error('Session name must not be empty');
114
+ }
115
+ if (submission.kind === 'shell' && submission.command.trim().length === 0) {
116
+ throw new Error('Shell command must not be empty');
117
+ }
118
+ }
119
+ terminalState(error, signal) {
120
+ return signal?.aborted === true ||
24
121
  error instanceof AgentRunCancelledError ||
25
- (error instanceof ModelProviderError && error.kind === 'cancelled');
26
- this.transition(cancelled ? 'cancelled' : 'failed');
122
+ (error instanceof ModelProviderError && error.kind === 'cancelled')
123
+ ? 'cancelled'
124
+ : 'failed';
125
+ }
126
+ transition(record, state) {
127
+ if (record.terminal)
128
+ return;
129
+ record.terminal = true;
130
+ this.options.eventSink({ type: 'state', state });
27
131
  }
28
- transition(state) {
29
- if (this.terminal) {
30
- throw new Error('Turn terminal transition already emitted');
132
+ rejectPending(items, reason) {
133
+ let firstFailure;
134
+ for (const item of items) {
135
+ try {
136
+ this.options.eventSink({
137
+ type: 'user-input-rejected',
138
+ id: item.id,
139
+ content: item.content,
140
+ reason,
141
+ });
142
+ }
143
+ catch (error) {
144
+ firstFailure ??= { error };
145
+ }
31
146
  }
32
- this.terminal = true;
33
- this.sink({ type: 'state', state });
147
+ return firstFailure;
34
148
  }
35
149
  }
36
150
  //# sourceMappingURL=turn-lifecycle.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.55.0",
3
+ "version": "0.55.1",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",