@markusylisiurunen/tau 0.3.37 → 0.3.39

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.
Files changed (81) hide show
  1. package/README.md +38 -4
  2. package/dist/core/agent/agent_runtime.js +22 -0
  3. package/dist/core/agent/agent_runtime.js.map +1 -1
  4. package/dist/core/cli.js +3 -0
  5. package/dist/core/cli.js.map +1 -1
  6. package/dist/core/config/content_loader.js +3 -1
  7. package/dist/core/config/content_loader.js.map +1 -1
  8. package/dist/core/config/index.js +1 -1
  9. package/dist/core/config/index.js.map +1 -1
  10. package/dist/core/config/schema.js +67 -0
  11. package/dist/core/config/schema.js.map +1 -1
  12. package/dist/core/history/cli.js +90 -0
  13. package/dist/core/history/cli.js.map +1 -0
  14. package/dist/core/history/config.js +11 -0
  15. package/dist/core/history/config.js.map +1 -0
  16. package/dist/core/history/history_manager.js +170 -0
  17. package/dist/core/history/history_manager.js.map +1 -0
  18. package/dist/core/history/local_history_store.js +407 -0
  19. package/dist/core/history/local_history_store.js.map +1 -0
  20. package/dist/core/history/remote_history_client.js +152 -0
  21. package/dist/core/history/remote_history_client.js.map +1 -0
  22. package/dist/core/history/replication.js +89 -0
  23. package/dist/core/history/replication.js.map +1 -0
  24. package/dist/core/history/setup.js +187 -0
  25. package/dist/core/history/setup.js.map +1 -0
  26. package/dist/core/history/transcript.js +39 -0
  27. package/dist/core/history/transcript.js.map +1 -0
  28. package/dist/core/history/types.js +2 -0
  29. package/dist/core/history/types.js.map +1 -0
  30. package/dist/core/models/tau_model_overrides.js +63 -4
  31. package/dist/core/models/tau_model_overrides.js.map +1 -1
  32. package/dist/core/personas.js +2 -1
  33. package/dist/core/personas.js.map +1 -1
  34. package/dist/core/runtime/chat_runtime.js +9 -0
  35. package/dist/core/runtime/chat_runtime.js.map +1 -1
  36. package/dist/core/static/code_mode/history/documentation.md +133 -0
  37. package/dist/core/static/code_mode/history/sandbox_runner.mjs +108 -0
  38. package/dist/core/static/code_mode/web/documentation.md +7 -7
  39. package/dist/core/subagents/agent_supervisor.js +1 -1
  40. package/dist/core/subagents/agent_supervisor.js.map +1 -1
  41. package/dist/core/subagents/registry.js +2 -1
  42. package/dist/core/subagents/registry.js.map +1 -1
  43. package/dist/core/subagents/types.js +2 -1
  44. package/dist/core/subagents/types.js.map +1 -1
  45. package/dist/core/telegram/adapter.js +30 -0
  46. package/dist/core/telegram/adapter.js.map +1 -1
  47. package/dist/core/telegram/session_manager.js +143 -26
  48. package/dist/core/telegram/session_manager.js.map +1 -1
  49. package/dist/core/tools/catalog.js +11 -2
  50. package/dist/core/tools/catalog.js.map +1 -1
  51. package/dist/core/tools/history.js +125 -0
  52. package/dist/core/tools/history.js.map +1 -0
  53. package/dist/core/tools/spawn_agent.js +1 -0
  54. package/dist/core/tools/spawn_agent.js.map +1 -1
  55. package/dist/core/tools/tool_names.js +2 -0
  56. package/dist/core/tools/tool_names.js.map +1 -1
  57. package/dist/core/tools/web.js +1 -1
  58. package/dist/core/tools/web.js.map +1 -1
  59. package/dist/core/utils/repository.js +44 -0
  60. package/dist/core/utils/repository.js.map +1 -0
  61. package/dist/core/version.js +1 -1
  62. package/dist/history/worker/index.js +1106 -0
  63. package/dist/history/worker/index.js.map +1 -0
  64. package/dist/host/local_session_host.js +285 -179
  65. package/dist/host/local_session_host.js.map +1 -1
  66. package/dist/main.js +30 -3
  67. package/dist/main.js.map +1 -1
  68. package/dist/protocol/session_protocol.d.ts +4 -1
  69. package/dist/protocol/session_protocol.js +14 -6
  70. package/dist/protocol/session_protocol.js.map +1 -1
  71. package/dist/sdk/local_client.js +5 -0
  72. package/dist/sdk/local_client.js.map +1 -1
  73. package/dist/store/session_snapshot_migrations.js +61 -1
  74. package/dist/store/session_snapshot_migrations.js.map +1 -1
  75. package/dist/tui/session_chat_app.js +3 -0
  76. package/dist/tui/session_chat_app.js.map +1 -1
  77. package/dist/tui/session_chat_controller.js +46 -16
  78. package/dist/tui/session_chat_controller.js.map +1 -1
  79. package/dist/tui/session_creation_attributes.js +66 -0
  80. package/dist/tui/session_creation_attributes.js.map +1 -0
  81. package/package.json +2 -1
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import { resolvePromptTemplateWithBackend } from "../core/config/index.js";
4
+ import { assistantHistoryEntries, toolHistoryEntry, userHistoryEntry, } from "../core/history/transcript.js";
4
5
  import { ChatRuntime } from "../core/runtime/chat_runtime.js";
5
6
  import { formatSteeringUserMessage } from "../core/runtime/steering.js";
6
7
  import { buildGoalContinuationText, prependGoalPolicy } from "../core/session/goal.js";
@@ -15,17 +16,22 @@ import { createExecutionEnvironmentSubagentRuntimeResolver } from "./execution_r
15
16
  import { HostedEphemeralAgentSession } from "./hosted_ephemeral_agent_session.js";
16
17
  import { SessionExecBusyError, SessionRetryUnavailableError, } from "./session_host.js";
17
18
  const PATH_AUTOCOMPLETE_CACHE_TTL_MS = 5_000;
19
+ const HISTORY_UNAVAILABLE_NOTICE_ID = "notice-history-unavailable";
18
20
  export class LocalSessionHost {
19
21
  sessions = new Set();
20
22
  sessionRecoveryPromises = new Map();
21
23
  store;
24
+ history;
25
+ historyRemote;
22
26
  clientToolBroker = new ClientToolBroker();
23
27
  sessionOptions;
24
28
  shutdownPromise;
25
29
  shuttingDown = false;
26
30
  constructor(options) {
27
- const { store, ...sessionOptions } = options;
31
+ const { store, history, historyRemote, ...sessionOptions } = options;
28
32
  this.store = store;
33
+ this.history = history;
34
+ this.historyRemote = historyRemote;
29
35
  this.sessionOptions = sessionOptions;
30
36
  }
31
37
  registerClientTools(options) {
@@ -108,9 +114,9 @@ export class LocalSessionHost {
108
114
  agentContextFiles: bootstrap.config?.agentContextFiles ?? [],
109
115
  });
110
116
  const catalog = createContentCatalogSnapshot(bootstrap);
111
- return this.createLocalSessionHandleFromRuntimeContext(executionEnvironment, runtimeContext, bootstrap, catalog, committedSnapshot, forceNextSnapshotRevision);
117
+ return this.createLocalSessionHandleFromRuntimeContext(executionEnvironment, runtimeContext, bootstrap, catalog, committedSnapshot, createParams, forceNextSnapshotRevision);
112
118
  }
113
- createLocalSessionHandleFromRuntimeContext(executionEnvironment, runtimeContext, bootstrap, catalog, committedSnapshot, forceNextSnapshotRevision = false) {
119
+ async createLocalSessionHandleFromRuntimeContext(executionEnvironment, runtimeContext, bootstrap, catalog, committedSnapshot, createParams, forceNextSnapshotRevision = false) {
114
120
  let hostedSession;
115
121
  const runtime = ChatRuntime.create({
116
122
  persona: bootstrap.persona,
@@ -131,6 +137,7 @@ export class LocalSessionHost {
131
137
  createGoal: async (objective) => await hostedSession.createGoal(objective),
132
138
  updateGoal: async (update) => await hostedSession.updateGoal(update),
133
139
  },
140
+ history: this.history.query(this.historyRemote),
134
141
  initialPromptComposition: committedSnapshot
135
142
  ? promptCompositionFromSnapshot(committedSnapshot)
136
143
  : undefined,
@@ -138,7 +145,18 @@ export class LocalSessionHost {
138
145
  recordUsage: this.sessionOptions.recordUsage,
139
146
  deps: this.sessionOptions.deps,
140
147
  });
141
- hostedSession = new LocalHostedSessionHandle(runtime, runtimeContext.promptBootstrap, catalog, bootstrap, this.sessionOptions.includeAgentContext, executionEnvironment, this.store, committedSnapshot, forceNextSnapshotRevision, this.sessionOptions.recordUsage ?? appendUsageLogEntry, (session) => this.sessions.delete(session));
148
+ const attributes = committedSnapshot?.attributes ?? createParams?.attributes;
149
+ if (!attributes) {
150
+ throw new Error("session attributes are required");
151
+ }
152
+ const createdAt = committedSnapshot?.createdAt ?? this.sessionOptions.environment.now();
153
+ hostedSession = new LocalHostedSessionHandle(runtime, runtimeContext.promptBootstrap, catalog, bootstrap, this.sessionOptions.includeAgentContext, executionEnvironment, this.store, this.history, this.historyRemote, attributes, createdAt, committedSnapshot, forceNextSnapshotRevision, this.sessionOptions.recordUsage ?? appendUsageLogEntry, (session) => this.sessions.delete(session));
154
+ const historyFailure = this.history.registerSession({
155
+ sessionId: committedSnapshot?.sessionId ?? hostedSession.sessionId,
156
+ attributes,
157
+ createdAt,
158
+ }, this.historyRemote);
159
+ await hostedSession.recordHistoryFailure(historyFailure);
142
160
  this.sessions.add(hostedSession);
143
161
  return hostedSession;
144
162
  }
@@ -295,6 +313,13 @@ export class LocalSessionHost {
295
313
  }
296
314
  }
297
315
  this.sessions.clear();
316
+ await this.history.flush();
317
+ try {
318
+ this.history.close();
319
+ }
320
+ catch (error) {
321
+ errors.push(error);
322
+ }
298
323
  if (errors.length > 0) {
299
324
  throw new AggregateError(errors, "failed to shut down local session host");
300
325
  }
@@ -326,6 +351,10 @@ class LocalHostedSessionHandle {
326
351
  includeAgentContext;
327
352
  executionEnvironment;
328
353
  store;
354
+ history;
355
+ historyRemote;
356
+ attributes;
357
+ createdAt;
329
358
  recordUsage;
330
359
  removeFromHost;
331
360
  session;
@@ -347,9 +376,7 @@ class LocalHostedSessionHandle {
347
376
  ephemeralAgentSessions = new Map();
348
377
  pathAutocompleteCache;
349
378
  pathAutocompleteLoad;
350
- runtimeEventQueue = Promise.resolve();
351
- snapshotQueue = Promise.resolve();
352
- snapshotGeneration = 0;
379
+ mutationQueue = Promise.resolve();
353
380
  activeWorkAbortControllers = new Set();
354
381
  activeWorkPromises = new Set();
355
382
  activeExecAbortControllers = new Map();
@@ -362,7 +389,7 @@ class LocalHostedSessionHandle {
362
389
  pendingGoalCommit;
363
390
  forceNextSnapshotRevision;
364
391
  disposed = false;
365
- constructor(runtime, promptBootstrap, catalog, bootstrap, includeAgentContext, executionEnvironment, store, committedSnapshot, forceNextSnapshotRevision, recordUsage, removeFromHost = () => { }) {
392
+ constructor(runtime, promptBootstrap, catalog, bootstrap, includeAgentContext, executionEnvironment, store, history, historyRemote, attributes, createdAt, committedSnapshot, forceNextSnapshotRevision, recordUsage, removeFromHost = () => { }) {
366
393
  this.runtime = runtime;
367
394
  this.promptBootstrap = promptBootstrap;
368
395
  this.catalog = catalog;
@@ -370,6 +397,10 @@ class LocalHostedSessionHandle {
370
397
  this.includeAgentContext = includeAgentContext;
371
398
  this.executionEnvironment = executionEnvironment;
372
399
  this.store = store;
400
+ this.history = history;
401
+ this.historyRemote = historyRemote;
402
+ this.attributes = attributes;
403
+ this.createdAt = createdAt;
373
404
  this.recordUsage = recordUsage;
374
405
  this.removeFromHost = removeFromHost;
375
406
  this.session = runtime;
@@ -447,8 +478,10 @@ class LocalHostedSessionHandle {
447
478
  async startGoal(objective) {
448
479
  const goal = this.buildNewGoal(objective);
449
480
  return await this.runLogicalTurn(async (logicalTurn) => {
450
- this.goal = goal;
451
- this.pendingGoalCommit = goal;
481
+ await this.enqueueMutation(() => {
482
+ this.goal = goal;
483
+ this.pendingGoalCommit = goal;
484
+ });
452
485
  try {
453
486
  const { userHistoryEntryId } = await this.record({
454
487
  text: prependGoalPolicy(goal.objective, goal),
@@ -466,8 +499,10 @@ class LocalHostedSessionHandle {
466
499
  }
467
500
  catch (error) {
468
501
  if (this.pendingGoalCommit === goal) {
469
- this.goal = null;
470
- this.pendingGoalCommit = undefined;
502
+ await this.enqueueMutation(() => {
503
+ this.goal = null;
504
+ this.pendingGoalCommit = undefined;
505
+ });
471
506
  }
472
507
  else {
473
508
  await this.blockActiveGoal().catch(() => undefined);
@@ -527,6 +562,9 @@ class LocalHostedSessionHandle {
527
562
  if (userMessage && hasGoalTurnMetadata(userMessage.message)) {
528
563
  throw new SessionRetryUnavailableError("goal-controlled turns cannot be retried; resume a blocked goal or start a new goal");
529
564
  }
565
+ if (!userMessage || !(await this.session.prepareRetry(userMessage.id))) {
566
+ throw new SessionRetryUnavailableError("no user turn is available to retry");
567
+ }
530
568
  return await this.runTurn();
531
569
  }
532
570
  async runLogicalTurn(execute) {
@@ -549,7 +587,7 @@ class LocalHostedSessionHandle {
549
587
  this.rejectBufferedLogicalSteering(logicalTurn, new Error("steering was not applied before the logical turn ended"));
550
588
  this.activeTurnPromise = undefined;
551
589
  this.activeLogicalTurn = undefined;
552
- await this.emitSnapshotResetIfChanged("assistant-message");
590
+ await this.enqueueSnapshotResetIfChanged("assistant-message");
553
591
  }
554
592
  }
555
593
  }
@@ -584,20 +622,22 @@ class LocalHostedSessionHandle {
584
622
  }
585
623
  const initialOutcome = turnOutcomeFromResult(result, result.finalMessage);
586
624
  const terminalOutcome = turnOutcomeFromResult(terminalResult, terminalResult.finalMessage);
587
- this.turnOutcomes.set(userMessage.id, initialOutcome);
588
- for (const steering of committedSteering) {
589
- this.turnOutcomes.set(steering.historyEntryId, initialOutcome);
590
- }
591
- if (this.goal?.status === "active") {
592
- goalRootHistoryEntryId ??= rootUserMessage.id;
593
- }
594
- if (goalRootHistoryEntryId) {
595
- this.turnOutcomes.set(goalRootHistoryEntryId, terminalOutcome);
596
- }
597
- if (terminalOutcome.status !== "completed") {
598
- await this.blockActiveGoal();
599
- }
600
- await this.emitSnapshotResetIfChanged("assistant-message");
625
+ await this.enqueueMutation(async () => {
626
+ this.turnOutcomes.set(userMessage.id, initialOutcome);
627
+ for (const steering of committedSteering) {
628
+ this.turnOutcomes.set(steering.historyEntryId, initialOutcome);
629
+ }
630
+ if (this.goal?.status === "active") {
631
+ goalRootHistoryEntryId ??= rootUserMessage.id;
632
+ }
633
+ if (goalRootHistoryEntryId) {
634
+ this.turnOutcomes.set(goalRootHistoryEntryId, terminalOutcome);
635
+ }
636
+ if (terminalOutcome.status !== "completed" && this.goal?.status === "active") {
637
+ this.goal = { ...this.goal, status: "blocked" };
638
+ }
639
+ await this.emitSnapshotResetIfChanged("assistant-message");
640
+ });
601
641
  this.resolveCommittedLogicalSteering(committedSteering, initialOutcome);
602
642
  if (logicalTurn.cancellationRequested) {
603
643
  return await this.cancelLogicalTurn(rootUserMessage.id);
@@ -612,8 +652,7 @@ class LocalHostedSessionHandle {
612
652
  }
613
653
  catch (error) {
614
654
  this.rejectCommittedLogicalSteering(committedSteering, error);
615
- await this.cleanupFailedTurn().catch(() => undefined);
616
- await this.blockActiveGoal().catch(() => undefined);
655
+ await this.cleanupFailedTurn(rootUserMessage.id, committedSteering, error).catch(() => undefined);
617
656
  throw error;
618
657
  }
619
658
  }
@@ -645,7 +684,7 @@ class LocalHostedSessionHandle {
645
684
  ...(this.activeTurnPromise ? [this.activeTurnPromise] : []),
646
685
  ...this.activeWorkPromises,
647
686
  ]);
648
- await this.runtimeEventQueue;
687
+ await this.mutationQueue;
649
688
  }
650
689
  requestTurnBoundaryStop() {
651
690
  this.assertActive();
@@ -731,9 +770,7 @@ class LocalHostedSessionHandle {
731
770
  }
732
771
  async setReasoning(reasoning) {
733
772
  this.assertActive();
734
- const write = this.runtimeEventQueue
735
- .catch(() => undefined)
736
- .then(async () => {
773
+ return await this.enqueueMutation(async () => {
737
774
  const fromRevision = this.committedSnapshot?.revision;
738
775
  this.runtime.setReasoning(reasoning);
739
776
  this.bootstrap.persona.settings.reasoning = reasoning;
@@ -755,8 +792,6 @@ class LocalHostedSessionHandle {
755
792
  }
756
793
  return { revision: snapshot.revision, settings: snapshot.settings };
757
794
  });
758
- this.runtimeEventQueue = write.then(() => undefined, () => undefined);
759
- return await write;
760
795
  }
761
796
  async setPersona(personaId) {
762
797
  this.assertActive();
@@ -774,23 +809,25 @@ class LocalHostedSessionHandle {
774
809
  includeAgentContext: this.includeAgentContext,
775
810
  agentContextFiles: runtimeConfig.config.agentContextFiles ?? [],
776
811
  });
777
- this.runtime.setRuntimeConfig(runtimeConfig.config, runtimeConfig.bootstrap.modelResolver.resolveModel);
778
- this.runtime.updatePromptContext(runtimeContext.promptBootstrap.promptContext);
779
- this.runtime.setPersona(selectedPersona, {
780
- skillsBlock: runtimeContext.promptBootstrap.promptContext.skillsBlock,
812
+ return await this.enqueueMutation(async () => {
813
+ this.runtime.setRuntimeConfig(runtimeConfig.config, runtimeConfig.bootstrap.modelResolver.resolveModel);
814
+ this.runtime.updatePromptContext(runtimeContext.promptBootstrap.promptContext);
815
+ this.runtime.setPersona(selectedPersona, {
816
+ skillsBlock: runtimeContext.promptBootstrap.promptContext.skillsBlock,
817
+ });
818
+ this.bootstrap = {
819
+ persona: clonePersona(selectedPersona),
820
+ discoveredSkills: structuredClone(runtimeConfig.skills),
821
+ personas: personas.map(clonePersona),
822
+ prompts: structuredClone(runtimeConfig.prompts),
823
+ modelResolver: runtimeConfig.bootstrap.modelResolver.resolveModel,
824
+ config: runtimeConfig.config,
825
+ };
826
+ this.catalog = createContentCatalogSnapshot(this.bootstrap);
827
+ const snapshot = await this.commitSnapshot();
828
+ this.emitSnapshotReset("configuration", snapshot);
829
+ return snapshot;
781
830
  });
782
- this.bootstrap = {
783
- persona: clonePersona(selectedPersona),
784
- discoveredSkills: structuredClone(runtimeConfig.skills),
785
- personas: personas.map(clonePersona),
786
- prompts: structuredClone(runtimeConfig.prompts),
787
- modelResolver: runtimeConfig.bootstrap.modelResolver.resolveModel,
788
- config: runtimeConfig.config,
789
- };
790
- this.catalog = createContentCatalogSnapshot(this.bootstrap);
791
- const snapshot = await this.commitSnapshot();
792
- this.emitSnapshotReset("configuration", snapshot);
793
- return snapshot;
794
831
  }
795
832
  async reload() {
796
833
  this.assertActive();
@@ -808,32 +845,34 @@ class LocalHostedSessionHandle {
808
845
  includeAgentContext: this.includeAgentContext,
809
846
  agentContextFiles: runtimeConfig.config.agentContextFiles ?? [],
810
847
  });
811
- this.runtime.setRuntimeConfig(runtimeConfig.config, runtimeConfig.bootstrap.modelResolver.resolveModel);
812
- this.runtime.updatePromptContext(runtimeContext.promptBootstrap.promptContext);
813
- this.runtime.setPersona(nextPersona, {
814
- skillsBlock: runtimeContext.promptBootstrap.promptContext.skillsBlock,
848
+ return await this.enqueueMutation(async () => {
849
+ this.runtime.setRuntimeConfig(runtimeConfig.config, runtimeConfig.bootstrap.modelResolver.resolveModel);
850
+ this.runtime.updatePromptContext(runtimeContext.promptBootstrap.promptContext);
851
+ this.runtime.setPersona(nextPersona, {
852
+ skillsBlock: runtimeContext.promptBootstrap.promptContext.skillsBlock,
853
+ });
854
+ this.bootstrap = {
855
+ persona: clonePersona(nextPersona),
856
+ discoveredSkills: structuredClone(runtimeConfig.skills),
857
+ personas: personas.map(clonePersona),
858
+ prompts: structuredClone(runtimeConfig.prompts),
859
+ modelResolver: runtimeConfig.bootstrap.modelResolver.resolveModel,
860
+ config: runtimeConfig.config,
861
+ };
862
+ this.catalog = createContentCatalogSnapshot(this.bootstrap);
863
+ const unknownSkillWarnings = runtimeContext.promptBootstrap.unknownSkills.map((skill) => `unknown skill enabled by persona '${nextPersona.id}': ${skill}`);
864
+ const snapshot = await this.commitSnapshot();
865
+ this.emitSnapshotReset("configuration", snapshot);
866
+ return {
867
+ snapshot,
868
+ warnings: [...runtimeConfig.warnings, ...unknownSkillWarnings],
869
+ counts: {
870
+ personas: runtimeConfig.personas.length,
871
+ prompts: runtimeConfig.prompts.length,
872
+ skills: runtimeConfig.skills.length,
873
+ },
874
+ };
815
875
  });
816
- this.bootstrap = {
817
- persona: clonePersona(nextPersona),
818
- discoveredSkills: structuredClone(runtimeConfig.skills),
819
- personas: personas.map(clonePersona),
820
- prompts: structuredClone(runtimeConfig.prompts),
821
- modelResolver: runtimeConfig.bootstrap.modelResolver.resolveModel,
822
- config: runtimeConfig.config,
823
- };
824
- this.catalog = createContentCatalogSnapshot(this.bootstrap);
825
- const unknownSkillWarnings = runtimeContext.promptBootstrap.unknownSkills.map((skill) => `unknown skill enabled by persona '${nextPersona.id}': ${skill}`);
826
- const snapshot = await this.commitSnapshot();
827
- this.emitSnapshotReset("configuration", snapshot);
828
- return {
829
- snapshot,
830
- warnings: [...runtimeConfig.warnings, ...unknownSkillWarnings],
831
- counts: {
832
- personas: runtimeConfig.personas.length,
833
- prompts: runtimeConfig.prompts.length,
834
- skills: runtimeConfig.skills.length,
835
- },
836
- };
837
876
  }
838
877
  normalizeReloadedPersona(persona) {
839
878
  return clonePersona(persona);
@@ -886,15 +925,16 @@ class LocalHostedSessionHandle {
886
925
  signal,
887
926
  });
888
927
  signal.throwIfAborted();
889
- await this.runtimeEventQueue;
890
- this.reconcileProjections();
891
- const snapshot = await this.commitSnapshot();
892
- this.emitSnapshotReset("maintenance", snapshot);
893
- return {
894
- snapshot,
895
- compactionMessage: result.compactionMessage,
896
- includedLastAssistant: result.includedLastAssistant,
897
- };
928
+ return await this.enqueueMutation(async () => {
929
+ this.reconcileProjections();
930
+ const snapshot = await this.commitSnapshot();
931
+ this.emitSnapshotReset("maintenance", snapshot);
932
+ return {
933
+ snapshot,
934
+ compactionMessage: result.compactionMessage,
935
+ includedLastAssistant: result.includedLastAssistant,
936
+ };
937
+ });
898
938
  });
899
939
  }
900
940
  async runMaintenance(operation) {
@@ -998,28 +1038,36 @@ class LocalHostedSessionHandle {
998
1038
  }
999
1039
  async snapshot() {
1000
1040
  this.assertActive();
1001
- return this.runtime.isTurnRunning && this.committedSnapshot
1041
+ return await this.enqueueMutation(async () => this.runtime.isTurnRunning && this.committedSnapshot
1002
1042
  ? await this.commitProjectedSnapshot()
1003
- : await this.commitSnapshot();
1043
+ : await this.commitSnapshot());
1004
1044
  }
1005
1045
  async persistRecoveredAgentState(recovery) {
1006
1046
  this.assertActive();
1007
- for (const recovered of recovery.recoveredToolResults) {
1008
- const tool = this.tools.get(recovered.message.toolCallId);
1009
- if (!tool || tool.status === "streaming") {
1010
- continue;
1011
- }
1012
- this.tools.set(tool.id, tool.status === "queued" || tool.status === "running"
1013
- ? {
1014
- ...tool,
1015
- status: "cancelled",
1016
- finishedAt: recovered.message.timestamp,
1017
- resultMessageId: recovered.historyEntryId,
1018
- error: "Tool completion status is unknown after session recovery.",
1047
+ await this.enqueueMutation(async () => {
1048
+ const recoveredTools = [];
1049
+ for (const recovered of recovery.recoveredToolResults) {
1050
+ const tool = this.tools.get(recovered.message.toolCallId);
1051
+ if (!tool || tool.status === "streaming") {
1052
+ continue;
1019
1053
  }
1020
- : { ...tool, resultMessageId: recovered.historyEntryId });
1021
- }
1022
- await this.commitSnapshot();
1054
+ const nextTool = tool.status === "queued" || tool.status === "running"
1055
+ ? {
1056
+ ...tool,
1057
+ status: "cancelled",
1058
+ finishedAt: recovered.message.timestamp,
1059
+ resultMessageId: recovered.historyEntryId,
1060
+ error: "Tool completion status is unknown after session recovery.",
1061
+ }
1062
+ : { ...tool, resultMessageId: recovered.historyEntryId };
1063
+ this.tools.set(tool.id, nextTool);
1064
+ recoveredTools.push({ tool: nextTool, result: recovered });
1065
+ }
1066
+ await this.commitSnapshot();
1067
+ for (const { tool, result } of recoveredTools) {
1068
+ await this.appendToolHistory(tool, result.historyEntryId, result.message);
1069
+ }
1070
+ });
1023
1071
  }
1024
1072
  async dispose() {
1025
1073
  if (!this.disposePromise) {
@@ -1081,15 +1129,10 @@ class LocalHostedSessionHandle {
1081
1129
  return { objective: normalized, status: "active" };
1082
1130
  }
1083
1131
  async setGoal(goal) {
1084
- const previousGoal = structuredClone(this.goal);
1085
- this.goal = structuredClone(goal);
1086
- try {
1132
+ await this.enqueueMutation(async () => {
1087
1133
  await this.emitPatch("goal", [{ type: "goal.set", goal: structuredClone(goal) }]);
1088
- }
1089
- catch (error) {
1090
- this.goal = previousGoal;
1091
- throw error;
1092
- }
1134
+ this.goal = structuredClone(goal);
1135
+ });
1093
1136
  }
1094
1137
  async blockActiveGoal() {
1095
1138
  if (this.goal?.status === "active") {
@@ -1174,37 +1217,21 @@ class LocalHostedSessionHandle {
1174
1217
  }
1175
1218
  async cancelLogicalTurn(rootHistoryEntryId, committedSteering = []) {
1176
1219
  const outcome = abortedTurnOutcome();
1177
- this.turnOutcomes.set(rootHistoryEntryId, outcome);
1178
- for (const steering of committedSteering) {
1179
- this.turnOutcomes.set(steering.historyEntryId, outcome);
1180
- }
1181
- await this.blockActiveGoal();
1182
- await this.emitSnapshotResetIfChanged("assistant-message");
1220
+ await this.enqueueMutation(async () => {
1221
+ this.turnOutcomes.set(rootHistoryEntryId, outcome);
1222
+ for (const steering of committedSteering) {
1223
+ this.turnOutcomes.set(steering.historyEntryId, outcome);
1224
+ }
1225
+ if (this.goal?.status === "active") {
1226
+ this.goal = { ...this.goal, status: "blocked" };
1227
+ }
1228
+ await this.emitSnapshotResetIfChanged("assistant-message");
1229
+ });
1183
1230
  this.resolveCommittedLogicalSteering(committedSteering, outcome);
1184
1231
  return outcome;
1185
1232
  }
1186
1233
  async commitSnapshot() {
1187
- const write = this.snapshotQueue.catch(() => undefined).then(() => this.writeSnapshot());
1188
- this.snapshotQueue = write.catch(() => undefined);
1189
- return await write;
1190
- }
1191
- async commitProjectedSnapshot() {
1192
- const write = this.snapshotQueue
1193
- .catch(() => undefined)
1194
- .then(() => this.writeProjectedSnapshot());
1195
- this.snapshotQueue = write.catch(() => undefined);
1196
- return await write;
1197
- }
1198
- async commitSnapshotPatch(reason, changes) {
1199
- const write = this.snapshotQueue
1200
- .catch(() => undefined)
1201
- .then(() => this.writeSnapshotPatch(reason, changes));
1202
- this.snapshotQueue = write.catch(() => undefined);
1203
- return await write;
1204
- }
1205
- async writeSnapshot() {
1206
1234
  this.assertNotDisposed();
1207
- const generation = this.snapshotGeneration;
1208
1235
  const draft = this.buildSnapshotDraft();
1209
1236
  await this.switchSnapshotSession(draft.sessionId);
1210
1237
  const snapshot = {
@@ -1218,17 +1245,14 @@ class LocalHostedSessionHandle {
1218
1245
  expectedRevision: this.persistedSnapshot?.revision ?? 0,
1219
1246
  });
1220
1247
  this.persistedSnapshot = cloneSessionProtocolSnapshot(snapshot);
1221
- if (generation !== this.snapshotGeneration) {
1222
- return await this.writeSnapshot();
1223
- }
1224
- return cloneSessionProtocolSnapshot(this.updateCommittedSnapshotAfterWrite(snapshot));
1248
+ this.committedSnapshot = cloneSessionProtocolSnapshot(snapshot);
1249
+ return cloneSessionProtocolSnapshot(snapshot);
1225
1250
  }
1226
- async writeProjectedSnapshot() {
1251
+ async commitProjectedSnapshot() {
1227
1252
  this.assertNotDisposed();
1228
- const generation = this.snapshotGeneration;
1229
1253
  const current = this.committedSnapshot;
1230
1254
  if (!current) {
1231
- return await this.writeSnapshot();
1255
+ return await this.commitSnapshot();
1232
1256
  }
1233
1257
  const snapshot = cloneSessionProtocolSnapshot(current);
1234
1258
  if (this.persistedSnapshot && isDeepStrictEqual(this.persistedSnapshot, snapshot)) {
@@ -1238,12 +1262,9 @@ class LocalHostedSessionHandle {
1238
1262
  expectedRevision: this.persistedSnapshot?.revision ?? 0,
1239
1263
  });
1240
1264
  this.persistedSnapshot = cloneSessionProtocolSnapshot(snapshot);
1241
- if (generation !== this.snapshotGeneration) {
1242
- return await this.writeProjectedSnapshot();
1243
- }
1244
- return cloneSessionProtocolSnapshot(this.committedSnapshot ?? snapshot);
1265
+ return snapshot;
1245
1266
  }
1246
- async writeSnapshotPatch(reason, changes) {
1267
+ async commitSnapshotPatch(reason, changes) {
1247
1268
  this.assertNotDisposed();
1248
1269
  const current = this.committedSnapshot;
1249
1270
  if (!current) {
@@ -1262,7 +1283,6 @@ class LocalHostedSessionHandle {
1262
1283
  });
1263
1284
  this.persistedSnapshot = cloneSessionProtocolSnapshot(snapshot);
1264
1285
  this.committedSnapshot = cloneSessionProtocolSnapshot(snapshot);
1265
- this.snapshotGeneration += 1;
1266
1286
  return delta;
1267
1287
  }
1268
1288
  async switchSnapshotSession(sessionId) {
@@ -1276,13 +1296,6 @@ class LocalHostedSessionHandle {
1276
1296
  this.committedSnapshot = undefined;
1277
1297
  this.persistedSnapshot = undefined;
1278
1298
  }
1279
- updateCommittedSnapshotAfterWrite(snapshot) {
1280
- if (!this.committedSnapshot || this.committedSnapshot.revision < snapshot.revision) {
1281
- this.committedSnapshot = cloneSessionProtocolSnapshot(snapshot);
1282
- this.snapshotGeneration += 1;
1283
- }
1284
- return this.committedSnapshot;
1285
- }
1286
1299
  reconcileProjections(options = {}) {
1287
1300
  const messageIds = new Set(this.session.rawHistoryEntries.map((entry) => entry.id));
1288
1301
  messageIds.add("system");
@@ -1325,6 +1338,8 @@ class LocalHostedSessionHandle {
1325
1338
  const agentState = this.session.snapshot();
1326
1339
  return {
1327
1340
  sessionId: this.session.sessionId,
1341
+ attributes: structuredClone(this.attributes),
1342
+ createdAt: this.createdAt,
1328
1343
  agentState: {
1329
1344
  revision: agentState.revision,
1330
1345
  contextEpoch: agentState.contextEpoch,
@@ -1440,7 +1455,8 @@ class LocalHostedSessionHandle {
1440
1455
  for (const [id, facet] of Object.entries(snapshot.facets)) {
1441
1456
  this.facets.set(id, structuredClone(facet));
1442
1457
  }
1443
- this.draftAssistantMessage = snapshot.messages.find((message) => message.state === "draft");
1458
+ const historyEntryIds = new Set(this.session.rawHistoryEntries.map((entry) => entry.id));
1459
+ this.draftAssistantMessage = snapshot.messages.find((message) => message.state === "draft" && !historyEntryIds.has(message.id));
1444
1460
  this.messageStates.clear();
1445
1461
  this.turnOutcomes.clear();
1446
1462
  for (const message of snapshot.messages) {
@@ -1452,6 +1468,24 @@ class LocalHostedSessionHandle {
1452
1468
  }
1453
1469
  }
1454
1470
  }
1471
+ enqueueMutation(mutation) {
1472
+ const result = this.mutationQueue
1473
+ .catch(() => undefined)
1474
+ .then(async () => {
1475
+ try {
1476
+ return await mutation();
1477
+ }
1478
+ catch (error) {
1479
+ if (this.committedSnapshot) {
1480
+ this.goal = structuredClone(this.committedSnapshot.goal);
1481
+ this.restoreProtocolState(this.committedSnapshot);
1482
+ }
1483
+ throw error;
1484
+ }
1485
+ });
1486
+ this.mutationQueue = result.then(() => undefined, () => undefined);
1487
+ return result;
1488
+ }
1455
1489
  assertActive() {
1456
1490
  if (this.disposing || this.disposed) {
1457
1491
  throw new Error(`session is shut down: ${this.committedSessionId}`);
@@ -1474,23 +1508,13 @@ class LocalHostedSessionHandle {
1474
1508
  return JSON.stringify(current) === JSON.stringify(next) ? revision : revision + 1;
1475
1509
  }
1476
1510
  async recordSubagentEvent(event) {
1477
- const write = this.runtimeEventQueue
1478
- .catch(() => undefined)
1479
- .then(() => this.recordSubagentUiEvent(event));
1480
- this.runtimeEventQueue = write.catch(() => undefined);
1481
- return await write;
1511
+ await this.enqueueMutation(() => this.recordSubagentUiEvent(event));
1482
1512
  }
1483
1513
  async enqueueRuntimeEvent(event) {
1484
- const write = this.runtimeEventQueue
1485
- .catch(() => undefined)
1486
- .then(() => this.recordRuntimeEvent(event));
1487
- this.runtimeEventQueue = write.catch(() => undefined);
1488
- return write;
1514
+ await this.enqueueMutation(() => this.recordRuntimeEvent(event));
1489
1515
  }
1490
1516
  async commitSteeringTurnOutcome(historyEntryId, turn) {
1491
- const write = this.runtimeEventQueue
1492
- .catch(() => undefined)
1493
- .then(async () => {
1517
+ await this.enqueueMutation(async () => {
1494
1518
  this.turnOutcomes.set(historyEntryId, turn);
1495
1519
  const message = this.committedSnapshot?.messages.find((candidate) => candidate.id === historyEntryId);
1496
1520
  if (!message) {
@@ -1506,8 +1530,6 @@ class LocalHostedSessionHandle {
1506
1530
  },
1507
1531
  ]);
1508
1532
  });
1509
- this.runtimeEventQueue = write.catch(() => undefined);
1510
- await write;
1511
1533
  }
1512
1534
  removeToolRun(tool, changes) {
1513
1535
  this.tools.delete(tool.id);
@@ -1517,6 +1539,43 @@ class LocalHostedSessionHandle {
1517
1539
  }
1518
1540
  changes.push({ type: "tool.remove", id: tool.id });
1519
1541
  }
1542
+ async recordHistoryFailure(reason) {
1543
+ if (!reason || this.timelineExtras.some((item) => item.id === HISTORY_UNAVAILABLE_NOTICE_ID)) {
1544
+ return;
1545
+ }
1546
+ const item = {
1547
+ type: "notice",
1548
+ id: HISTORY_UNAVAILABLE_NOTICE_ID,
1549
+ notice: {
1550
+ severity: "warn",
1551
+ text: `Session history is unavailable: ${reason}. This session will continue without durable history recording or recall.`,
1552
+ timestamp: Date.now(),
1553
+ },
1554
+ };
1555
+ this.timelineExtras.push(item);
1556
+ await this.emitPatch("notice", [{ type: "timeline.append", item: structuredClone(item) }]);
1557
+ }
1558
+ async appendToolHistory(tool, resultHistoryEntryId, result) {
1559
+ const callMessage = this.committedSnapshot?.messages.find((message) => message.id === tool.call.messageId);
1560
+ const call = callMessage?.message.role === "assistant"
1561
+ ? callMessage.message.content[tool.call.contentIndex]
1562
+ : undefined;
1563
+ if (call?.type !== "toolCall" || call.id !== tool.toolCallId) {
1564
+ throw new Error(`missing transcript tool call '${tool.toolCallId}'`);
1565
+ }
1566
+ if (tool.status === "queued" || tool.status === "running") {
1567
+ throw new Error(`transcript tool call '${tool.toolCallId}' is not terminal`);
1568
+ }
1569
+ await this.recordHistoryFailure(this.history.append(this.sessionId, [
1570
+ toolHistoryEntry({
1571
+ callHistoryEntryId: tool.call.messageId,
1572
+ resultHistoryEntryId,
1573
+ call,
1574
+ result,
1575
+ outcome: tool.status,
1576
+ }),
1577
+ ], this.historyRemote));
1578
+ }
1520
1579
  agentStateChange() {
1521
1580
  const state = this.session.snapshot();
1522
1581
  return {
@@ -1659,6 +1718,7 @@ class LocalHostedSessionHandle {
1659
1718
  timelineItem: timelineItemForMessage(event.historyEntryId),
1660
1719
  },
1661
1720
  ], { persist: true });
1721
+ await this.recordHistoryFailure(this.history.append(this.sessionId, [userHistoryEntry(event.historyEntryId, event.message)], this.historyRemote));
1662
1722
  if (pendingGoal && this.pendingGoalCommit === pendingGoal) {
1663
1723
  this.pendingGoalCommit = undefined;
1664
1724
  }
@@ -1667,6 +1727,7 @@ class LocalHostedSessionHandle {
1667
1727
  case "history_rewound":
1668
1728
  this.reconcileProjections({ removeMissingAgents: true });
1669
1729
  await this.emitSnapshotResetIfChanged("maintenance");
1730
+ await this.recordHistoryFailure(this.history.truncateFromSources(this.sessionId, event.removedEntryIds, this.historyRemote));
1670
1731
  return;
1671
1732
  case "assistant_final": {
1672
1733
  this.draftAssistantMessage = undefined;
@@ -1713,6 +1774,7 @@ class LocalHostedSessionHandle {
1713
1774
  : []),
1714
1775
  ...toolChanges,
1715
1776
  ]);
1777
+ await this.recordHistoryFailure(this.history.append(this.sessionId, assistantHistoryEntries(event.historyEntryId, event.message), this.historyRemote));
1716
1778
  return;
1717
1779
  }
1718
1780
  case "tool_run_queued":
@@ -1771,6 +1833,7 @@ class LocalHostedSessionHandle {
1771
1833
  },
1772
1834
  { type: "tool.set", tool: structuredClone(nextTool) },
1773
1835
  ]);
1836
+ await this.appendToolHistory(existing, event.historyEntryId, event.message);
1774
1837
  return;
1775
1838
  }
1776
1839
  await this.emitPatch("tool-result", [
@@ -1801,6 +1864,7 @@ class LocalHostedSessionHandle {
1801
1864
  },
1802
1865
  },
1803
1866
  ];
1867
+ const historyTools = [];
1804
1868
  for (const toolResult of event.toolResults) {
1805
1869
  const existing = this.tools.get(toolResult.toolCallId);
1806
1870
  if (!existing || existing.status === "streaming") {
@@ -1814,8 +1878,12 @@ class LocalHostedSessionHandle {
1814
1878
  };
1815
1879
  this.tools.set(nextTool.id, nextTool);
1816
1880
  changes.push({ type: "tool.set", tool: structuredClone(nextTool) });
1881
+ historyTools.push({ tool: existing, result: toolResult });
1817
1882
  }
1818
1883
  await this.emitPatch("recovery", changes);
1884
+ for (const { tool, result } of historyTools) {
1885
+ await this.appendToolHistory(tool, event.historyEntryId, result);
1886
+ }
1819
1887
  return;
1820
1888
  }
1821
1889
  case "notice": {
@@ -1952,7 +2020,6 @@ class LocalHostedSessionHandle {
1952
2020
  if (options.persist === false) {
1953
2021
  const snapshot = { ...this.buildSnapshotDraft(), revision: 1 };
1954
2022
  this.committedSnapshot = snapshot;
1955
- this.snapshotGeneration += 1;
1956
2023
  this.emitSnapshotReset(reason, snapshot);
1957
2024
  return;
1958
2025
  }
@@ -1972,7 +2039,6 @@ class LocalHostedSessionHandle {
1972
2039
  delta: { type: "snapshot.patch", changes },
1973
2040
  });
1974
2041
  this.committedSnapshot = applySessionProtocolDelta(this.committedSnapshot, delta);
1975
- this.snapshotGeneration += 1;
1976
2042
  this.emitDelta(delta);
1977
2043
  }
1978
2044
  emitSnapshotReset(reason, snapshot) {
@@ -1984,6 +2050,9 @@ class LocalHostedSessionHandle {
1984
2050
  delta: { type: "snapshot.reset", snapshot },
1985
2051
  }));
1986
2052
  }
2053
+ async enqueueSnapshotResetIfChanged(reason) {
2054
+ await this.enqueueMutation(() => this.emitSnapshotResetIfChanged(reason));
2055
+ }
1987
2056
  async emitSnapshotResetIfChanged(reason) {
1988
2057
  const previousRevision = this.committedSnapshot?.revision;
1989
2058
  const snapshot = await this.commitSnapshot();
@@ -2049,14 +2118,51 @@ class LocalHostedSessionHandle {
2049
2118
  const interruptedMessage = createInterruptedAssistantMessage(draft, this.runtime.persona.model);
2050
2119
  await this.session.commitInterruptedAssistant(interruptedMessage, draft.id);
2051
2120
  }
2052
- async cleanupFailedTurn() {
2121
+ async cleanupFailedTurn(rootHistoryEntryId, committedSteering, error) {
2053
2122
  if (this.draftAssistantMessage) {
2054
- await this.interruptDraftAssistantMessage();
2055
- return;
2056
- }
2057
- await this.emitSnapshotResetIfChanged("assistant-message");
2123
+ await this.interruptDraftAssistantMessage().catch(() => undefined);
2124
+ }
2125
+ const diagnostic = formatErrorDiagnostic(error);
2126
+ await this.enqueueMutation(async () => {
2127
+ const outcome = {
2128
+ status: "failed",
2129
+ stopReason: "error",
2130
+ errorMessage: diagnostic,
2131
+ };
2132
+ this.turnOutcomes.set(rootHistoryEntryId, outcome);
2133
+ for (const steering of committedSteering) {
2134
+ this.turnOutcomes.set(steering.historyEntryId, outcome);
2135
+ }
2136
+ if (this.goal?.status === "active") {
2137
+ this.goal = { ...this.goal, status: "blocked" };
2138
+ }
2139
+ const timestamp = Date.now();
2140
+ for (const [id, tool] of this.tools) {
2141
+ if (tool.status !== "queued" && tool.status !== "running") {
2142
+ continue;
2143
+ }
2144
+ this.tools.set(id, {
2145
+ ...tool,
2146
+ status: "cancelled",
2147
+ finishedAt: timestamp,
2148
+ error: `Turn failed before tool completion: ${diagnostic}`,
2149
+ });
2150
+ }
2151
+ await this.emitSnapshotResetIfChanged("assistant-message");
2152
+ });
2058
2153
  }
2059
2154
  }
2155
+ function formatErrorDiagnostic(error) {
2156
+ const messages = [];
2157
+ const seen = new Set();
2158
+ let current = error;
2159
+ while (current !== undefined && !seen.has(current)) {
2160
+ seen.add(current);
2161
+ messages.push(current instanceof Error ? current.message : String(current));
2162
+ current = current instanceof Error ? current.cause : undefined;
2163
+ }
2164
+ return messages.filter((message, index) => message && message !== messages[index - 1]).join(": ");
2165
+ }
2060
2166
  function abortedTurnOutcome() {
2061
2167
  return { status: "aborted", stopReason: "aborted" };
2062
2168
  }