@wowyuarm/dsh-agent-team 0.1.8 → 0.1.10
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 +4 -11
- package/README.zh.md +4 -11
- package/package.json +54 -44
- package/packages/agent-team/README.md +7 -5
- package/packages/agent-team/README.zh.md +7 -4
- package/packages/agent-team/lib/attachments.js +14 -4
- package/packages/agent-team/lib/context-management.js +382 -0
- package/packages/agent-team/lib/context-projection.js +578 -0
- package/packages/agent-team/lib/context-source.js +212 -0
- package/packages/agent-team/lib/index.js +1272 -76
- package/packages/agent-team/lib/ledger.js +333 -44
- package/packages/agent-team/lib/member-context.js +6 -2
- package/packages/agent-team/lib/member-runtime.js +159 -4
- package/packages/agent-team/lib/member-time-context.js +126 -0
- package/packages/agent-team/lib/pressure-policy.js +199 -0
- package/packages/agent-team/lib/progress-nudge.js +12 -4
- package/packages/agent-team/lib/session-remediation.js +481 -0
- package/packages/agent-team/lib/spec.js +21 -2
- package/packages/agent-team/lib/time-format.js +56 -0
- package/packages/agent-team/lib/typert.host.js +168 -54
- package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
- package/packages/agent-team/lib/typert.remote-client.js +59 -25
- package/packages/agent-team/lib/types/attachments.d.ts +1 -1
- package/packages/agent-team/lib/types/attachments.d.ts.map +1 -1
- package/packages/agent-team/lib/types/context-management.d.ts +147 -0
- package/packages/agent-team/lib/types/context-management.d.ts.map +1 -0
- package/packages/agent-team/lib/types/context-projection.d.ts +219 -0
- package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -0
- package/packages/agent-team/lib/types/context-source.d.ts +117 -0
- package/packages/agent-team/lib/types/context-source.d.ts.map +1 -0
- package/packages/agent-team/lib/types/index.d.ts +300 -2
- package/packages/agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/types/ledger.d.ts +119 -6
- package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-context.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-runtime.d.ts +30 -2
- package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-time-context.d.ts +83 -0
- package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -0
- package/packages/agent-team/lib/types/pressure-policy.d.ts +106 -0
- package/packages/agent-team/lib/types/pressure-policy.d.ts.map +1 -0
- package/packages/agent-team/lib/types/progress-nudge.d.ts.map +1 -1
- package/packages/agent-team/lib/types/session-remediation.d.ts +149 -0
- package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -0
- package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
- package/packages/agent-team/lib/types/time-format.d.ts +28 -0
- package/packages/agent-team/lib/types/time-format.d.ts.map +1 -0
- package/packages/agent-team/lib/types/types/entities.d.ts +22 -1
- package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/operations.d.ts +38 -3
- package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/requests-results.d.ts +58 -1
- package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
- package/packages/agent-team/preset/team-member/agent.cordis.yml +27 -4
- package/packages/client-agent-team/lib/client.js +369 -352
- package/packages/client-agent-team/lib/client.js.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts +1 -2
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +61 -41
- package/packages/client-agent-team/lib/types/client/TeamMessage.js +2 -2
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +4 -4
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +2 -2
- package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/index.js +44 -16
- package/packages/client-agent-team/lib/types/client/locales.d.ts +0 -14
- package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/locales.js +0 -14
- package/packages/client-agent-team/lib/types/client/slots.d.ts +6 -1
- package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
- package/packages/tool-agent-team/README.md +11 -6
- package/packages/tool-agent-team/README.zh.md +11 -6
- package/packages/tool-agent-team/lib/context-tools.js +179 -0
- package/packages/tool-agent-team/lib/index.js +369 -58
- package/packages/tool-agent-team/lib/types/context-tools.d.ts +16 -0
- package/packages/tool-agent-team/lib/types/context-tools.d.ts.map +1 -0
- package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/auto-compaction.js +0 -201
- package/packages/agent-team/lib/types/auto-compaction.d.ts +0 -47
- package/packages/agent-team/lib/types/auto-compaction.d.ts.map +0 -1
|
@@ -46,25 +46,48 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent';
|
|
|
46
46
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
47
47
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
48
48
|
import { scopeOf } from '@deepseek-ai/dsh-scope';
|
|
49
|
-
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
49
|
+
import { Session, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session';
|
|
50
|
+
import { SessionFormatUnsupportedError } from '@deepseek-ai/dsh-session-persistence';
|
|
50
51
|
import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy';
|
|
51
52
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
52
53
|
import { ATTACHMENT_MAX_BYTES, attachmentPayloadPath, attachmentsRoot, copyPathAttachment, newAttachmentId, readAttachment, sanitizeMediaType, sweepAttachmentCache, validatePathAttachment, writeAttachment } from "./attachments.js";
|
|
53
|
-
import {
|
|
54
|
+
import { PressurePolicyCoordinator } from "./pressure-policy.js";
|
|
55
|
+
import { ContextManagementCoordinator } from "./context-management.js";
|
|
56
|
+
import { AGENT_TEAM_PLUGIN_ID, createHandoffMessage } from "./context-source.js";
|
|
57
|
+
import { carriedInputOf, checkpointByRef, checkpointRefFor, foldContextProjection, isReminderNoticeSummary, timelineCandidates } from "./context-projection.js";
|
|
54
58
|
import { AGENT_TEAM_HUMAN_MEMBER_ID, AgentTeamLedger, agentTeamHumanActor } from "./ledger.js";
|
|
55
|
-
import { AGENT_TEAM_TOOL_NAMES, deepCopyCapabilities, MemberRuntime } from "./member-runtime.js";
|
|
59
|
+
import { AGENT_TEAM_TOOL_NAMES, deepCopyCapabilities, memberMemoryDirectoryName, MemberRuntime } from "./member-runtime.js";
|
|
56
60
|
import { ProgressNudgeCoordinator } from "./progress-nudge.js";
|
|
57
61
|
import { classifyRecoverableError, RecoveryCoordinator, RECOVERY_MAX_CONSECUTIVE_ERRORS } from "./recovery.js";
|
|
62
|
+
import { SessionRemediation, handoffAlreadyInLog } from "./session-remediation.js";
|
|
58
63
|
import { agentTeamDomainSpec } from "./spec.js";
|
|
64
|
+
import { formatTeamTimestamp } from "./time-format.js";
|
|
59
65
|
export { agentTeamDomainSpec, agentTeamOperationSchema } from "./spec.js";
|
|
60
66
|
export { AGENT_TEAM_HUMAN_MEMBER_ID, AGENT_TEAM_INITIALIZE_REQUEST_ID } from "./ledger.js";
|
|
61
67
|
export { AGENT_TEAM_TOOL_NAMES } from "./member-runtime.js";
|
|
62
68
|
/** Process-stable marker carried by the final Team message tool definition. */
|
|
63
69
|
export const AGENT_TEAM_PRESET_MARKER = Symbol.for('@wowyuarm/dsh-agent-team.preset');
|
|
64
|
-
const AGENT_TEAM_PLUGIN_ID = '@wowyuarm/dsh-agent-team';
|
|
65
70
|
const INBOX_NOTICE_SUMMARY = 'Team Inbox has unread work.';
|
|
66
71
|
const RECOVERY_NOTICE_SUMMARY = 'Recovery: continue your interrupted work.';
|
|
67
72
|
const ORPHANED_MEMBER_DIAGNOSTIC = 'Member preset composition was lost after a reload; its tools are unavailable. Resume rebuilds the member in place.';
|
|
73
|
+
/** Longest accepted model-supplied checkpoint display name. */
|
|
74
|
+
const MAX_CHECKPOINT_NAME_CHARS = 120;
|
|
75
|
+
/** Default and maximum number of timeline items one query returns. */
|
|
76
|
+
const DEFAULT_TIMELINE_LIMIT = 12;
|
|
77
|
+
const MAX_TIMELINE_LIMIT = 24;
|
|
78
|
+
/** Deepest ancestor lineage the timeline and seed resolution walk. */
|
|
79
|
+
const MAX_TIMELINE_ANCESTORS = 8;
|
|
80
|
+
/** Product pressure budget constants (see docs/team-collaboration.md). */
|
|
81
|
+
const CONTEXT_HARD_LIMIT_CAP = 256_000;
|
|
82
|
+
const CONTEXT_HANDOFF_AT_CAP = 200_000;
|
|
83
|
+
const CONTEXT_HANDOFF_RESERVE = 8_000;
|
|
84
|
+
const CONTEXT_SAFE_OUTPUT_RESERVE = 16_000;
|
|
85
|
+
/**
|
|
86
|
+
* Usage at or above which an acknowledged acceptance advises a fresh
|
|
87
|
+
* rollover instead of keeping the context; capped by the route's effective
|
|
88
|
+
* handoff budget so narrow routes get a proportionally earlier boundary.
|
|
89
|
+
*/
|
|
90
|
+
const ACCEPT_TASK_BOUNDARY_THRESHOLD = 128_000;
|
|
68
91
|
function sameChangeScope(left, right) {
|
|
69
92
|
if (left.kind === 'workspace' && right.kind === 'workspace')
|
|
70
93
|
return left.workspaceId === right.workspaceId;
|
|
@@ -79,6 +102,35 @@ export function markAgentTeamPreset(definition) {
|
|
|
79
102
|
Object.defineProperty(definition, AGENT_TEAM_PRESET_MARKER, { value: true });
|
|
80
103
|
return definition;
|
|
81
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Whether the running dsh loads workspace packages from source via tsx.
|
|
107
|
+
*
|
|
108
|
+
* The Harness `tsconfig.base.json` maps `@deepseek-ai/*` package names onto
|
|
109
|
+
* `src/` directories; tsx honors those paths, so a CLI launched with
|
|
110
|
+
* `node --import tsx/esm apps/cli/src/bin.ts` imports `@deepseek-ai/dsh-scope`
|
|
111
|
+
* from `src/index.ts` while a profile-installed bundle resolves the compiled
|
|
112
|
+
* `lib/index.js` — two module instances with independent scope keys.
|
|
113
|
+
*/
|
|
114
|
+
export function isTsxDevMode() {
|
|
115
|
+
const flags = [...process.execArgv];
|
|
116
|
+
const nodeOptions = process.env.NODE_OPTIONS;
|
|
117
|
+
if (nodeOptions !== undefined)
|
|
118
|
+
flags.push(...nodeOptions.split(/\s+/));
|
|
119
|
+
return flags.some(flag => flag.includes('tsx'));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The activation diagnostic for a dsh-scope module-instance mismatch.
|
|
123
|
+
*
|
|
124
|
+
* `agentPresets.mount` already rejected an unscoped context, so a scope key
|
|
125
|
+
* the harness sees but this bundle does not can only mean the two sides
|
|
126
|
+
* loaded different physical copies of `@deepseek-ai/dsh-scope`.
|
|
127
|
+
*/
|
|
128
|
+
export function teamPresetScopeMismatchMessage(tsxDevMode) {
|
|
129
|
+
if (tsxDevMode) {
|
|
130
|
+
return 'selected preset is not team-enabled: the dsh CLI is running from source via tsx (tsconfig paths resolve @deepseek-ai/dsh-scope to src/), so the harness and this bundle load different module instances; start dsh with the compiled CLI instead (pnpm dsh, node apps/cli/lib/bin.js, or npx @deepseek-ai/dsh)';
|
|
131
|
+
}
|
|
132
|
+
return 'selected preset is not team-enabled: this bundle and the harness resolved different physical copies of @deepseek-ai/dsh-scope; run pnpm install in the profile directory so node_modules matches the lockfile, then restart';
|
|
133
|
+
}
|
|
82
134
|
/**
|
|
83
135
|
* A DM was durably recorded but its session injection could not run (no live
|
|
84
136
|
* handle, or the wake itself failed). The recorded DM stays durable; the
|
|
@@ -217,7 +269,7 @@ let AgentTeam = (() => {
|
|
|
217
269
|
* stale keys.
|
|
218
270
|
*/
|
|
219
271
|
memberFailures = new Map();
|
|
220
|
-
|
|
272
|
+
pressurePolicy;
|
|
221
273
|
notifiedInbox = new Map();
|
|
222
274
|
attachmentGcTimer;
|
|
223
275
|
recovery = new RecoveryCoordinator({
|
|
@@ -245,38 +297,46 @@ let AgentTeam = (() => {
|
|
|
245
297
|
},
|
|
246
298
|
log: message => { this.ctx.logger.warn(message); },
|
|
247
299
|
});
|
|
300
|
+
/**
|
|
301
|
+
* Context self-management: the one deep module that turns a Member's
|
|
302
|
+
* successful `context_rollover` tool result into its next private context
|
|
303
|
+
* generation. The ledger owns the binding audit, the Session projection
|
|
304
|
+
* owns intent, and this coordinator owns only reconstructible process
|
|
305
|
+
* state. See docs/architecture.md and docs/team-collaboration.md.
|
|
306
|
+
*/
|
|
307
|
+
contextManagement = new ContextManagementCoordinator({
|
|
308
|
+
agentForMember: memberId => this.handles.get(memberId)?.agent,
|
|
309
|
+
memberForAgent: agent => this.memberForAgent(agent),
|
|
310
|
+
projectionForMember: (memberId, sessionId) => {
|
|
311
|
+
const handle = this.handles.get(memberId);
|
|
312
|
+
if (handle === undefined || handle.agent.session.id !== sessionId)
|
|
313
|
+
return undefined;
|
|
314
|
+
return foldContextProjection(handle.agent.session.ownEvents(), handle.agent.session.inheritedEventCount, handle.agent.session.id);
|
|
315
|
+
},
|
|
316
|
+
executeTransition: (memberId, plan) => this.executeMemberTransition(memberId, plan),
|
|
317
|
+
log: message => { this.ctx.logger.warn(`agent-team: ${message}`); },
|
|
318
|
+
});
|
|
248
319
|
lifecycleTail = Promise.resolve();
|
|
249
320
|
accepting = true;
|
|
250
321
|
changeVersion = 0;
|
|
251
322
|
changeWaiters = new Set();
|
|
252
323
|
constructor(ctx) {
|
|
253
324
|
super(ctx, 'agentTeam');
|
|
254
|
-
this.
|
|
325
|
+
this.pressurePolicy = new PressurePolicyCoordinator({
|
|
255
326
|
agentForMember: memberId => this.handles.get(memberId)?.agent,
|
|
327
|
+
memberForAgent: agent => {
|
|
328
|
+
const member = this.memberForAgent(agent);
|
|
329
|
+
return member === undefined ? undefined : { memberId: member.memberId, sessionId: agent.id };
|
|
330
|
+
},
|
|
256
331
|
compactionForAgent: agent => this.ctx.agentPresets.serviceFor(agent, 'compaction'),
|
|
257
|
-
|
|
332
|
+
limitsForAgent: agent => this.routeLimitsForAgent(agent),
|
|
333
|
+
activeClaimLabels: memberId => this.activeClaimLabels(memberId),
|
|
334
|
+
runningJobLabels: memberId => this.runningJobLabels(memberId),
|
|
258
335
|
failed: (memberId, _sessionId, diagnostic) => {
|
|
259
336
|
this.setMemberFailure(memberId, 'compaction', diagnostic);
|
|
260
337
|
this.emitAutoCompactionChanged(memberId);
|
|
261
338
|
},
|
|
262
|
-
cleared: memberId => {
|
|
263
|
-
if (!this.clearMemberFailure(memberId, 'compaction'))
|
|
264
|
-
return;
|
|
265
|
-
this.emitAutoCompactionChanged(memberId);
|
|
266
|
-
},
|
|
267
339
|
log: message => { this.ctx.logger.warn(`agent-team: ${message}`); },
|
|
268
|
-
// Advisory only: the Agent decides whether anything is worth persisting.
|
|
269
|
-
steerPreCompaction: agent => {
|
|
270
|
-
const hint = createUserMessage({
|
|
271
|
-
content: [{ type: 'text', text: preCompactionNoticeText() }],
|
|
272
|
-
source: { kind: 'plugin', plugin: AGENT_TEAM_PLUGIN_ID, form: 'notice', summary: PRE_COMPACTION_NOTICE_SUMMARY },
|
|
273
|
-
});
|
|
274
|
-
// Persist-your-conclusions outranks a queued progress nudge.
|
|
275
|
-
const member = this.memberForAgent(agent);
|
|
276
|
-
if (member !== undefined)
|
|
277
|
-
this.progressNudge.revokePendingNotice(member.memberId);
|
|
278
|
-
agent.steer(hint);
|
|
279
|
-
},
|
|
280
340
|
});
|
|
281
341
|
}
|
|
282
342
|
/** Open the durable ledger and restore every enabled Member independently. */
|
|
@@ -304,7 +364,12 @@ let AgentTeam = (() => {
|
|
|
304
364
|
if (recovered)
|
|
305
365
|
this.notifiedInbox.delete(member.memberId);
|
|
306
366
|
this.emitChanged([{ kind: 'workspace', workspaceId: member.workspaceId }]);
|
|
307
|
-
|
|
367
|
+
// A rollover/recovery in flight delivers its own sequenced
|
|
368
|
+
// rederived Inbox after the handoff and carried input; a status-driven
|
|
369
|
+
// steer here would claim the handoff turn's next step and leapfrog
|
|
370
|
+
// the carried messages.
|
|
371
|
+
if (!this.contextManagement.isTransitioning(member.memberId))
|
|
372
|
+
this.notifyMember(agent);
|
|
308
373
|
}
|
|
309
374
|
// A turn that ends without an error closes any automatic recovery episode.
|
|
310
375
|
// The idle transition is itself presence-affecting (working → available),
|
|
@@ -334,13 +399,21 @@ let AgentTeam = (() => {
|
|
|
334
399
|
if (handle === undefined || handle.agent.session.id !== session.id)
|
|
335
400
|
return;
|
|
336
401
|
this.progressNudge.onSessionEvent(memberId, session.id, handle.agent, event);
|
|
402
|
+
// A successful assistant response ends any open provider-overflow
|
|
403
|
+
// recovery sequence for this Member.
|
|
404
|
+
if (event.type === 'assistant/message')
|
|
405
|
+
this.pressurePolicy.onAssistantMessage(handle.agent);
|
|
406
|
+
// Context management reacts only after a successful durable tool/result;
|
|
407
|
+
// the projection (not this listener) decides what that means.
|
|
408
|
+
this.contextManagement.onSessionEvent(memberId, handle.agent, event);
|
|
337
409
|
});
|
|
338
410
|
const domain = await this.ctx.storageDomain.open(agentTeamDomainSpec);
|
|
339
411
|
this.ctx.effect(() => async () => {
|
|
340
412
|
this.accepting = false;
|
|
341
413
|
this.recovery.dispose();
|
|
342
414
|
this.progressNudge.dispose();
|
|
343
|
-
|
|
415
|
+
this.contextManagement.dispose();
|
|
416
|
+
this.pressurePolicy.dispose();
|
|
344
417
|
if (this.attachmentGcTimer !== undefined)
|
|
345
418
|
clearInterval(this.attachmentGcTimer);
|
|
346
419
|
this.attachmentGcTimer = undefined;
|
|
@@ -360,9 +433,20 @@ let AgentTeam = (() => {
|
|
|
360
433
|
if (initialization.committed)
|
|
361
434
|
this.emitCommitted(initialization.value);
|
|
362
435
|
this.startAttachmentGc(ledger);
|
|
436
|
+
// Legacy-artifact remediation runs before any Member activation: no write
|
|
437
|
+
// lease exists yet, so publishing sibling generations for refused Session
|
|
438
|
+
// logs cannot race a live writer. Remediation failure never blocks
|
|
439
|
+
// startup — the next start retries exactly what the cache does not cover.
|
|
440
|
+
try {
|
|
441
|
+
const remediation = new SessionRemediation(this.ctx, this.ctx.sessionPersistence, await SessionRemediation.open(this.ctx));
|
|
442
|
+
await remediation.remediateEnabledMembers(ledger.listMembers());
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
this.ctx.logger.warn(`agent-team: legacy Session remediation did not run to completion (it will retry on the next start): ${error instanceof Error ? error.message : String(error)}`);
|
|
446
|
+
}
|
|
363
447
|
// One metadata listing serves every Member restore; per-member list calls
|
|
364
448
|
// would repeat the same I/O linearly during startup.
|
|
365
|
-
const persistedSessions = new Set((await this.
|
|
449
|
+
const persistedSessions = new Set((await this.persistedSessionHeaders()).map(snapshot => snapshot.header.id));
|
|
366
450
|
for (const member of ledger.listMembers()) {
|
|
367
451
|
if (member.state === 'enabled')
|
|
368
452
|
await this.activateMember(member, undefined, persistedSessions);
|
|
@@ -370,6 +454,17 @@ let AgentTeam = (() => {
|
|
|
370
454
|
await this.memberRuntime.cleanupRemovedMember(member);
|
|
371
455
|
}
|
|
372
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* Whether one Member Session has durable persisted content, decided through
|
|
459
|
+
* {@link SessionPersistence.stat} rather than a bare metadata listing: the
|
|
460
|
+
* backend reports a still-draining session through its pending header, so a
|
|
461
|
+
* resume racing a suspend's fire-and-forget final flush cannot mistake a
|
|
462
|
+
* still-draining persisted Session for an unpersisted one and fork a fresh
|
|
463
|
+
* generation over it.
|
|
464
|
+
*/
|
|
465
|
+
async sessionPersisted(sessionId) {
|
|
466
|
+
return (await this.ctx.sessionPersistence.stat(sessionId)) !== undefined;
|
|
467
|
+
}
|
|
373
468
|
/** Resolve one exact live Agent to its durable Team Member; forks do not inherit identity. */
|
|
374
469
|
memberForAgent(agent) {
|
|
375
470
|
for (const [memberId, handle] of this.handles) {
|
|
@@ -497,7 +592,7 @@ let AgentTeam = (() => {
|
|
|
497
592
|
presetId: request.presetId,
|
|
498
593
|
...(request.model === undefined ? {} : { model: Object.freeze({ ...request.model }) }),
|
|
499
594
|
...(request.capabilities === undefined ? {} : { capabilities: Object.freeze(deepCopyCapabilities(request.capabilities)) }),
|
|
500
|
-
privateMemoryPath: dshHomePath('agent-team', 'members', memberId),
|
|
595
|
+
privateMemoryPath: dshHomePath('agent-team', 'members', memberMemoryDirectoryName(memberId)),
|
|
501
596
|
state: 'enabled',
|
|
502
597
|
});
|
|
503
598
|
const result = await this.requireLedger().addMember({ ...request, actor: agentTeamHumanActor(), member });
|
|
@@ -526,7 +621,13 @@ let AgentTeam = (() => {
|
|
|
526
621
|
if (result.committed)
|
|
527
622
|
this.emitCommitted(result.value.receipt);
|
|
528
623
|
this.clearMemberNotificationState(result.value.member.memberId);
|
|
529
|
-
|
|
624
|
+
// The suspended Session's log is durable once its retirement completes,
|
|
625
|
+
// and agents.resume() waits for exactly that retirement before loading.
|
|
626
|
+
// Consulting the persistence tree here instead would race the
|
|
627
|
+
// fire-and-forget retirement on Windows, where the JSONL backend
|
|
628
|
+
// publishes directories through transient staging entries that surface
|
|
629
|
+
// as ENOENT mid-walk — so pass the known session rather than re-listing.
|
|
630
|
+
await this.activateMember(result.value.member, undefined, new Set([result.value.member.sessionId]));
|
|
530
631
|
return Object.freeze({ receipt: result.value.receipt, status: this.memberStatus(result.value.member) });
|
|
531
632
|
});
|
|
532
633
|
}
|
|
@@ -604,22 +705,7 @@ let AgentTeam = (() => {
|
|
|
604
705
|
return Object.freeze({ receipt: result.value.receipt, status: this.memberStatus(result.value.member) });
|
|
605
706
|
}
|
|
606
707
|
const renewed = result.value.member;
|
|
607
|
-
|
|
608
|
-
// error markers belong to the disposed agent, not to the Member.
|
|
609
|
-
this.recovery.stopTracking(request.memberId);
|
|
610
|
-
// The fresh Session may re-earn one Claim suggestion per Thread; the
|
|
611
|
-
// old Session's one-shot records must not leak into it.
|
|
612
|
-
this.progressNudge.stopTracking(request.memberId);
|
|
613
|
-
this.memberBySessionId.delete(previousSessionId);
|
|
614
|
-
await active.dispose();
|
|
615
|
-
this.handles.delete(request.memberId);
|
|
616
|
-
this.modelSelections.delete(request.memberId);
|
|
617
|
-
this.memberRuntime.forgetMember(request.memberId);
|
|
618
|
-
this.clearMemberFailure(request.memberId, 'activation');
|
|
619
|
-
this.clearMemberNotificationState(request.memberId);
|
|
620
|
-
// The previous log survives on disk; archiving hides it from every
|
|
621
|
-
// grouping surface so one Member keeps exactly one visible Session.
|
|
622
|
-
await this.ctx.workspaceRegistry.archiveSession(previousSessionId);
|
|
708
|
+
await this.retireMemberGeneration(request.memberId, active, previousSessionId);
|
|
623
709
|
await this.activateMember(renewed, undefined, undefined, previousSessionId);
|
|
624
710
|
const reactivated = this.handles.get(request.memberId);
|
|
625
711
|
if (reactivated === undefined) {
|
|
@@ -630,6 +716,126 @@ let AgentTeam = (() => {
|
|
|
630
716
|
return Object.freeze({ receipt: result.value.receipt, status: this.memberStatus(renewed) });
|
|
631
717
|
});
|
|
632
718
|
}
|
|
719
|
+
/**
|
|
720
|
+
* Retire one Member's previous generation after its durable binding moved
|
|
721
|
+
* onto a new Session id: drop the old handle's transient state, dispose the
|
|
722
|
+
* Agent, and archive the old Session log (which stays on disk for history).
|
|
723
|
+
* Shared by the Human clear path and the model-initiated rollover.
|
|
724
|
+
*/
|
|
725
|
+
async retireMemberGeneration(memberId, active, previousSessionId) {
|
|
726
|
+
// Drop the old handle's transient state: pending recovery episodes and
|
|
727
|
+
// error markers belong to the disposed agent, not to the Member.
|
|
728
|
+
this.recovery.stopTracking(memberId);
|
|
729
|
+
// The fresh Session may re-earn one Claim suggestion per Thread; the
|
|
730
|
+
// old Session's one-shot records must not leak into it.
|
|
731
|
+
this.progressNudge.stopTracking(memberId);
|
|
732
|
+
// The context admission gate stays armed through disposal: input racing
|
|
733
|
+
// the retire window must still be captured for the new generation, and
|
|
734
|
+
// the coordinator drops its own bookkeeping only after the swap settles.
|
|
735
|
+
this.memberBySessionId.delete(previousSessionId);
|
|
736
|
+
await active.dispose();
|
|
737
|
+
this.handles.delete(memberId);
|
|
738
|
+
this.modelSelections.delete(memberId);
|
|
739
|
+
this.memberRuntime.forgetMember(memberId);
|
|
740
|
+
this.clearMemberFailure(memberId, 'activation');
|
|
741
|
+
this.clearMemberNotificationState(memberId);
|
|
742
|
+
// The previous log survives on disk; archiving hides it from every
|
|
743
|
+
// grouping surface so one Member keeps exactly one visible Session.
|
|
744
|
+
await this.ctx.workspaceRegistry.archiveSession(previousSessionId);
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Execute one prepared context rollover at a true idle boundary: commit the
|
|
748
|
+
* idempotent Member-actor operation, retire the previous generation, and
|
|
749
|
+
* activate the fresh Session whose first model-facing context is the
|
|
750
|
+
* Member's own handoff. Later non-Team input captured during the transition
|
|
751
|
+
* is delivered after the handoff; the Team Inbox is rederived from the
|
|
752
|
+
* ledger, never copied.
|
|
753
|
+
*/
|
|
754
|
+
async executeMemberTransition(memberId, plan) {
|
|
755
|
+
await this.enqueueLifecycle(async () => {
|
|
756
|
+
this.requireAccepting();
|
|
757
|
+
const stored = this.requireLedger().getMember(memberId);
|
|
758
|
+
if (stored === undefined || stored.state !== 'enabled')
|
|
759
|
+
throw new Error(`Agent Member '${memberId}' cannot roll over: not enabled`);
|
|
760
|
+
if (stored.sessionId !== plan.previousSessionId)
|
|
761
|
+
throw new Error(`Agent Member '${stored.handle}' is no longer bound to the rolled-over Session`);
|
|
762
|
+
const active = this.handles.get(memberId);
|
|
763
|
+
if (active === undefined)
|
|
764
|
+
throw new Error(`Agent Member '${stored.handle}' has no active session to roll over`);
|
|
765
|
+
// A racing turn the admission gate rejects still leaves the Agent
|
|
766
|
+
// momentarily running; wait for its convergence instead of failing the
|
|
767
|
+
// swap — the gate guarantees it spends no model request.
|
|
768
|
+
if (this.runningAgents.has(active.agent.id))
|
|
769
|
+
await active.agent.whenIdle();
|
|
770
|
+
if (this.runningAgents.has(active.agent.id))
|
|
771
|
+
throw new Error(`Agent Member '${stored.handle}' is still running; the rollover must wait for idle`);
|
|
772
|
+
// Checkpoint return: resolve the seed before committing anything. A
|
|
773
|
+
// violation found here fails the whole swap with the old generation
|
|
774
|
+
// intact — never a guessed seed over a wrong prefix.
|
|
775
|
+
const seed = plan.checkpointRef === undefined ? undefined : await this.resolveCheckpointSeed(memberId, active.agent, plan.checkpointRef);
|
|
776
|
+
// Recheck the job guard at the lifecycle commit seam: a job may have
|
|
777
|
+
// started or settled after the tool-time validation.
|
|
778
|
+
const blockingJobs = this.ownedJobsBlockingRollover(active.agent);
|
|
779
|
+
if (blockingJobs.length > 0) {
|
|
780
|
+
throw new Error(`the context rollover is refused: this Member now owns jobs that would not survive the switch (${blockingJobs.join(', ')}); collect or stop them, then retry`);
|
|
781
|
+
}
|
|
782
|
+
const rolled = await this.rolloverSessionForAgent(active.agent, {
|
|
783
|
+
requestId: plan.requestId,
|
|
784
|
+
workspaceId: stored.workspaceId,
|
|
785
|
+
memberId,
|
|
786
|
+
previousSessionId: plan.previousSessionId,
|
|
787
|
+
newSessionId: plan.newSessionId,
|
|
788
|
+
handoffEventSeq: plan.handoffEventSeq,
|
|
789
|
+
trigger: plan.trigger,
|
|
790
|
+
...(seed === undefined ? {} : { checkpointRef: seed.checkpointRef, sourceSessionId: seed.sourceSessionId, sourceThroughSeq: seed.sourceThroughSeq }),
|
|
791
|
+
});
|
|
792
|
+
// The durable old-log projection is the carried-input truth: after the
|
|
793
|
+
// old Agent retires, fold its final state and take every post-intent
|
|
794
|
+
// non-Team candidate the old generation never answered. The process
|
|
795
|
+
// capture only accelerates; it is unioned by message id, never allowed
|
|
796
|
+
// to override the fold.
|
|
797
|
+
const preRetireCapture = this.contextManagement.drainCapturedInput(memberId);
|
|
798
|
+
await this.retireMemberGeneration(memberId, active, plan.previousSessionId);
|
|
799
|
+
const finalState = foldContextProjection(active.agent.session.ownEvents(), active.agent.session.inheritedEventCount, active.agent.session.id);
|
|
800
|
+
const foldedCarried = carriedInputOf(finalState);
|
|
801
|
+
const carriedById = new Map(plan.carriedInput.map(message => [message.id, message]));
|
|
802
|
+
for (const message of foldedCarried)
|
|
803
|
+
carriedById.set(message.id, message);
|
|
804
|
+
for (const message of preRetireCapture)
|
|
805
|
+
if (!carriedById.has(message.id))
|
|
806
|
+
carriedById.set(message.id, message);
|
|
807
|
+
for (const message of this.contextManagement.drainCapturedInput(memberId))
|
|
808
|
+
if (!carriedById.has(message.id))
|
|
809
|
+
carriedById.set(message.id, message);
|
|
810
|
+
const carriedInput = [...carriedById.values()];
|
|
811
|
+
// A fresh rollover seeds nothing and points the lineage parent at the
|
|
812
|
+
// previous active Session; a checkpoint return seeds the resolved
|
|
813
|
+
// prefix and parents at the seed source Session instead. Activation
|
|
814
|
+
// defers the ordinary Inbox wake so the handoff is guaranteed to be the
|
|
815
|
+
// new generation's first model-facing context.
|
|
816
|
+
const seedEvents = seed === undefined ? undefined : seed.prefix;
|
|
817
|
+
await this.activateMember(rolled.member, undefined, undefined, seed === undefined ? plan.previousSessionId : seed.sourceSessionId, {
|
|
818
|
+
deferNotify: true,
|
|
819
|
+
// The prefix is contiguous from seq 0, so its length is exactly the
|
|
820
|
+
// inherited cut the child folds past.
|
|
821
|
+
...(seedEvents === undefined ? {} : { seed: seedEvents, inheritedEventCount: SessionLogOffset(seedEvents.length) }),
|
|
822
|
+
});
|
|
823
|
+
const reactivated = this.handles.get(memberId);
|
|
824
|
+
if (reactivated === undefined) {
|
|
825
|
+
throw new Error(`Agent Member '${stored.handle}' failed to activate its next context: ${this.memberFailures.get(memberId)?.activation ?? 'unknown error'}`);
|
|
826
|
+
}
|
|
827
|
+
// The handoff is the first model-facing context of the new generation.
|
|
828
|
+
// It rides the step-priority inbox lane (steer) so a later rederived
|
|
829
|
+
// Inbox notice queues behind it instead of preempting it; carried input
|
|
830
|
+
// follows as its own turn, and the Inbox is rederived from ledger facts.
|
|
831
|
+
reactivated.agent.steer(this.contextManagement.handoffMessageFor(plan));
|
|
832
|
+
for (const message of carriedInput)
|
|
833
|
+
reactivated.agent.followup(message);
|
|
834
|
+
const notifications = this.requireLedger().notificationFacts(memberId, { workspaceId: rolled.member.workspaceId });
|
|
835
|
+
if (notifications.length > 0)
|
|
836
|
+
this.notifyMember(reactivated.agent, carriedInput.length > 0);
|
|
837
|
+
});
|
|
838
|
+
}
|
|
633
839
|
/** Ledger handle for log lines; falls back to the raw id when unknown. */
|
|
634
840
|
memberLabel(memberId) {
|
|
635
841
|
return this.ledger?.getMember(memberId)?.handle ?? memberId;
|
|
@@ -943,7 +1149,60 @@ let AgentTeam = (() => {
|
|
|
943
1149
|
const result = await this.requireLedger().readThread({ ...request, actor });
|
|
944
1150
|
if (result.committed)
|
|
945
1151
|
this.emitCommitted(result.value.receipt);
|
|
946
|
-
|
|
1152
|
+
const value = result.value;
|
|
1153
|
+
// Private read-time enrich: an acceptance the reader just acknowledged is
|
|
1154
|
+
// a natural Task boundary, so the Host prices the reader's context once,
|
|
1155
|
+
// after the durable read has committed. Never a ledger fact, never
|
|
1156
|
+
// persisted, and a measurement failure degrades to an explicit
|
|
1157
|
+
// `unavailable` — the committed read is never reversed.
|
|
1158
|
+
const advice = await this.acceptanceContextAdvice(agent, value);
|
|
1159
|
+
return advice === undefined ? value : Object.freeze({ ...value, contextAdvice: advice });
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Context advice for one acceptance acknowledged by this read: only when
|
|
1163
|
+
* the read carried an unread accept activity AND the Task is still done.
|
|
1164
|
+
* Repeat reads (nothing unread), history-style reads, and reopened Tasks
|
|
1165
|
+
* carry no advice — the acceptance no longer stands.
|
|
1166
|
+
*/
|
|
1167
|
+
async acceptanceContextAdvice(agent, read) {
|
|
1168
|
+
if (read.task === undefined || read.task.resolution !== 'accepted' || read.task.status !== 'done')
|
|
1169
|
+
return undefined;
|
|
1170
|
+
const acknowledgedAccept = read.facts.some(entry => entry.unread && entry.fact.kind === 'activity'
|
|
1171
|
+
&& entry.fact.activity.kind === 'accept');
|
|
1172
|
+
if (!acknowledgedAccept)
|
|
1173
|
+
return undefined;
|
|
1174
|
+
return this.contextAdviceFor(agent);
|
|
1175
|
+
}
|
|
1176
|
+
/** Price the reading Member's context against its current route's budgets. */
|
|
1177
|
+
async contextAdviceFor(agent) {
|
|
1178
|
+
try {
|
|
1179
|
+
const limits = await this.routeLimitsForAgent(agent);
|
|
1180
|
+
if (limits === undefined)
|
|
1181
|
+
return this.unavailableAdvice();
|
|
1182
|
+
const taskBoundaryThreshold = Math.min(ACCEPT_TASK_BOUNDARY_THRESHOLD, limits.handoffAt);
|
|
1183
|
+
if (limits.usageTokens >= limits.handoffAt) {
|
|
1184
|
+
return Object.freeze({ usageTokens: limits.usageTokens, taskBoundaryThreshold, handoffAt: limits.handoffAt, hardLimit: limits.hardLimit,
|
|
1185
|
+
action: 'handoff-now',
|
|
1186
|
+
guidance: 'You are at or above the handoff budget. Finish the current atomic action and unsettled evidence, then call context_rollover with a fresh handoff now.' });
|
|
1187
|
+
}
|
|
1188
|
+
if (limits.usageTokens >= taskBoundaryThreshold) {
|
|
1189
|
+
return Object.freeze({ usageTokens: limits.usageTokens, taskBoundaryThreshold, handoffAt: limits.handoffAt, hardLimit: limits.hardLimit,
|
|
1190
|
+
action: 'rollover',
|
|
1191
|
+
guidance: 'Finish the acceptance closeout, persist only durable reusable conclusions, collect or stop jobs, then call context_rollover with a fresh handoff covering every other active Claim. Do not return to an old checkpoint solely because this Task was accepted.' });
|
|
1192
|
+
}
|
|
1193
|
+
return Object.freeze({ usageTokens: limits.usageTokens, taskBoundaryThreshold, handoffAt: limits.handoffAt, hardLimit: limits.hardLimit,
|
|
1194
|
+
action: 'keep',
|
|
1195
|
+
guidance: 'Keep the current context for possible acceptance follow-up. This acceptance is already a timeline boundary; do not create a redundant checkpoint. Record a checkpoint only before the next noisy or risky phase.' });
|
|
1196
|
+
}
|
|
1197
|
+
catch {
|
|
1198
|
+
// Any measurement failure degrades explicitly; the read stays durable.
|
|
1199
|
+
return this.unavailableAdvice();
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
unavailableAdvice() {
|
|
1203
|
+
return Object.freeze({ usageTokens: undefined, taskBoundaryThreshold: undefined, handoffAt: undefined, hardLimit: undefined,
|
|
1204
|
+
action: 'unavailable',
|
|
1205
|
+
guidance: 'Context usage could not be measured for this acceptance; manage context by your existing pressure policy.' });
|
|
947
1206
|
}
|
|
948
1207
|
/**
|
|
949
1208
|
* Agent-only direct message: append the audit-only dm-sent operation, then
|
|
@@ -965,7 +1224,7 @@ let AgentTeam = (() => {
|
|
|
965
1224
|
}
|
|
966
1225
|
try {
|
|
967
1226
|
const message = createUserMessage({
|
|
968
|
-
content: [{ type: 'text', text: this.dmRelayText(agent, recipient, request.body.trim(), result.value.receipt.operationId) }],
|
|
1227
|
+
content: [{ type: 'text', text: this.dmRelayText(agent, recipient, request.body.trim(), result.value.receipt.occurredAt, result.value.receipt.operationId) }],
|
|
969
1228
|
source: { kind: 'plugin', plugin: AGENT_TEAM_PLUGIN_ID, form: 'relay' },
|
|
970
1229
|
});
|
|
971
1230
|
// An idle recipient gets one ordinary turn; a busy one is steered into
|
|
@@ -981,10 +1240,10 @@ let AgentTeam = (() => {
|
|
|
981
1240
|
return result.value;
|
|
982
1241
|
}
|
|
983
1242
|
/** Relay body: the DM itself plus one bounded line of adjacent context. */
|
|
984
|
-
dmRelayText(senderAgent, recipient, body, excluding) {
|
|
1243
|
+
dmRelayText(senderAgent, recipient, body, occurredAt, excluding) {
|
|
985
1244
|
const sender = this.memberForAgent(senderAgent);
|
|
986
1245
|
const prior = this.requireLedger().dmHistoryBetween(senderAgent.id, recipient.memberId, excluding);
|
|
987
|
-
const header = `Direct message from @${sender?.handle ?? 'a Team Member'}:`;
|
|
1246
|
+
const header = `Direct message from @${sender?.handle ?? 'a Team Member'} at ${formatTeamTimestamp(occurredAt)}:`;
|
|
988
1247
|
const context = prior === undefined ? '' : `\n\n[most recent prior DM between you: ${prior}]`;
|
|
989
1248
|
return `${header}\n\n${body}${context}`;
|
|
990
1249
|
}
|
|
@@ -1006,6 +1265,715 @@ let AgentTeam = (() => {
|
|
|
1006
1265
|
validateLedger() {
|
|
1007
1266
|
this.requireLedger().validate();
|
|
1008
1267
|
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Effective context-pressure budget for one Member's current route:
|
|
1270
|
+
* `hardLimit = min(256K, routeWindow - outputReserve)` and
|
|
1271
|
+
* `handoffAt = min(200K, hardLimit - handoffReserve)`.
|
|
1272
|
+
*/
|
|
1273
|
+
contextLimits(routeWindow) {
|
|
1274
|
+
const hardLimit = Math.min(CONTEXT_HARD_LIMIT_CAP, Math.max(0, routeWindow - CONTEXT_SAFE_OUTPUT_RESERVE));
|
|
1275
|
+
const handoffAt = Math.min(CONTEXT_HANDOFF_AT_CAP, Math.max(0, hardLimit - CONTEXT_HANDOFF_RESERVE));
|
|
1276
|
+
return { hardLimit, handoffAt };
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Budget + measurement for one Member's CURRENT routed selection — the
|
|
1280
|
+
* member-pinned model or the default selection, resolved through the LLM
|
|
1281
|
+
* service so a route change is honored at the next pre-step. The last
|
|
1282
|
+
* persisted `request/context` event wins when it matches the current
|
|
1283
|
+
* selection (no directory round-trip for an unchanged route). A selection
|
|
1284
|
+
* whose capacity cannot be resolved returns `undefined` and the pressure
|
|
1285
|
+
* policy fails closed instead of assuming an unbounded route.
|
|
1286
|
+
*/
|
|
1287
|
+
async routeLimitsForAgent(agent) {
|
|
1288
|
+
const member = this.memberForAgent(agent);
|
|
1289
|
+
if (member === undefined)
|
|
1290
|
+
return undefined;
|
|
1291
|
+
// The pressure budget must match the route the CURRENT step actually
|
|
1292
|
+
// uses: the live selection ref's `assembled` capture when the step has
|
|
1293
|
+
// entered prompt assembly, its `current` selection otherwise. Re-deriving
|
|
1294
|
+
// from the ledger or the Host default would race a concurrent default
|
|
1295
|
+
// change or live model edit and split the budget from the real route.
|
|
1296
|
+
const ref = this.modelSelections.get(member.memberId);
|
|
1297
|
+
const selection = ref?.assembled ?? ref?.current ?? member.model ?? this.ctx.agentDefaultModel.currentSelection();
|
|
1298
|
+
const meter = agent.ctx.get('tokenMeter');
|
|
1299
|
+
const usageTokens = meter?.measure(agent.session)?.totalTokens;
|
|
1300
|
+
if (usageTokens === undefined)
|
|
1301
|
+
return undefined;
|
|
1302
|
+
const persisted = agent.session.requestContext();
|
|
1303
|
+
let contextWindow;
|
|
1304
|
+
if (persisted?.provider === selection.provider && persisted.model === selection.model) {
|
|
1305
|
+
contextWindow = persisted.contextWindow;
|
|
1306
|
+
}
|
|
1307
|
+
if (contextWindow === undefined) {
|
|
1308
|
+
contextWindow = await this.resolvedContextWindow(selection);
|
|
1309
|
+
}
|
|
1310
|
+
if (contextWindow === undefined)
|
|
1311
|
+
return undefined;
|
|
1312
|
+
const { hardLimit, handoffAt } = this.contextLimits(contextWindow);
|
|
1313
|
+
return { usageTokens, hardLimit, handoffAt };
|
|
1314
|
+
}
|
|
1315
|
+
/** Cache of resolved context windows per provider/model; unknown stays unknown. */
|
|
1316
|
+
routeWindows = new Map();
|
|
1317
|
+
/**
|
|
1318
|
+
* Resolve one selection's provider-owned context capacity, cached per
|
|
1319
|
+
* route. The LLM service is resolved lazily (not a hard inject): the Host
|
|
1320
|
+
* mounts it in every production shape, while ledger-level fixtures provide
|
|
1321
|
+
* only the services the Team service itself owns. A missing or throwing
|
|
1322
|
+
* resolution is an unknown route — the pressure policy fails closed on it.
|
|
1323
|
+
*/
|
|
1324
|
+
async resolvedContextWindow(selection) {
|
|
1325
|
+
const key = `${selection.provider}::${selection.model}`;
|
|
1326
|
+
if (this.routeWindows.has(key))
|
|
1327
|
+
return this.routeWindows.get(key);
|
|
1328
|
+
let contextWindow;
|
|
1329
|
+
try {
|
|
1330
|
+
const llm = this.ctx.get('llm');
|
|
1331
|
+
const info = llm === undefined ? undefined : await llm.resolveModelInfo(selection.provider, selection.model);
|
|
1332
|
+
contextWindow = info?.context?.contextWindow;
|
|
1333
|
+
}
|
|
1334
|
+
catch {
|
|
1335
|
+
contextWindow = undefined;
|
|
1336
|
+
}
|
|
1337
|
+
this.routeWindows.set(key, contextWindow);
|
|
1338
|
+
return contextWindow;
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Resolve one checkpoint ref to its exact seed prefix before any rollover
|
|
1342
|
+
* commit. The walk covers the current generation (own events) and archived
|
|
1343
|
+
* ancestors through `sessionPersistence`; the same projection definition
|
|
1344
|
+
* folds every source. Guards fail closed: unresolved, foreign-lineage,
|
|
1345
|
+
* open-turn, or nonshrinking targets reject without any lifecycle effect.
|
|
1346
|
+
*/
|
|
1347
|
+
async resolveCheckpointSeed(memberId, agent, checkpointRef) {
|
|
1348
|
+
let sessionId = agent.session.id;
|
|
1349
|
+
let live = true;
|
|
1350
|
+
let guard = 0;
|
|
1351
|
+
while (sessionId !== undefined && guard++ < MAX_TIMELINE_ANCESTORS) {
|
|
1352
|
+
let events;
|
|
1353
|
+
let inheritedEventCount;
|
|
1354
|
+
let parentSession;
|
|
1355
|
+
// Capture this iteration's source identity BEFORE the branch advances
|
|
1356
|
+
// the lineage flag: the measurement call below keys on it.
|
|
1357
|
+
const sourceIsCurrent = live;
|
|
1358
|
+
if (live) {
|
|
1359
|
+
events = agent.session.snapshotEvents();
|
|
1360
|
+
inheritedEventCount = agent.session.inheritedEventCount;
|
|
1361
|
+
parentSession = agent.session.header.parentSession;
|
|
1362
|
+
live = false;
|
|
1363
|
+
}
|
|
1364
|
+
else {
|
|
1365
|
+
try {
|
|
1366
|
+
const handle = await this.ctx.sessionPersistence.open(sessionId, 'read');
|
|
1367
|
+
try {
|
|
1368
|
+
events = (await handle.read()).events;
|
|
1369
|
+
inheritedEventCount = handle.inheritedEventCount;
|
|
1370
|
+
parentSession = handle.header.parentSession;
|
|
1371
|
+
}
|
|
1372
|
+
finally {
|
|
1373
|
+
await handle.close();
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
catch (error) {
|
|
1377
|
+
throw new Error(`checkpoint '${checkpointRef}' could not be resolved: its source Session is unreadable (${error instanceof Error ? error.message : String(error)})`);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
// Fold the source with its inherited cut respected: inherited events
|
|
1381
|
+
// are resolved history in that source, never fresh intent; checkpoints
|
|
1382
|
+
// recorded in this source's own span are the selectable targets.
|
|
1383
|
+
const state = foldContextProjection(events, inheritedEventCount, sessionId);
|
|
1384
|
+
// A Team-boundary default checkpoint: the boundary's completed-turn
|
|
1385
|
+
// anchor is the seed cut, and it is selectable exactly when one
|
|
1386
|
+
// Thread's facts entered the context through it — the same proof the
|
|
1387
|
+
// timeline requires, revalidated here because the model may cite a
|
|
1388
|
+
// boundary the timeline never surfaced.
|
|
1389
|
+
const boundary = checkpointRef.startsWith('team-boundary-')
|
|
1390
|
+
? state.boundaries.find(entry => entry.key === checkpointRef)
|
|
1391
|
+
: undefined;
|
|
1392
|
+
const entry = checkpointByRef(state, checkpointRef);
|
|
1393
|
+
const anchorTurnEndSeq = boundary !== undefined ? boundary.turnEndSeq : entry?.turnEndSeq;
|
|
1394
|
+
if (anchorTurnEndSeq !== undefined && anchorTurnEndSeq !== -1) {
|
|
1395
|
+
if (boundary !== undefined && boundary.source !== 'team-boundary') {
|
|
1396
|
+
throw new Error(`checkpoint '${checkpointRef}' is not a restorable boundary`);
|
|
1397
|
+
}
|
|
1398
|
+
// The seed is the exact contiguous prefix through the anchor's
|
|
1399
|
+
// completed turn end. Balanced by construction — the turn ended.
|
|
1400
|
+
const throughSeq = anchorTurnEndSeq + 1;
|
|
1401
|
+
const prefix = events.slice(0, throughSeq);
|
|
1402
|
+
if (boundary !== undefined) {
|
|
1403
|
+
const threads = this.threadsEnteringContext(events, anchorTurnEndSeq);
|
|
1404
|
+
if (threads.length !== 1) {
|
|
1405
|
+
throw new Error(threads.length === 0
|
|
1406
|
+
? `boundary '${checkpointRef}' has no single attributable Thread; write a fresh handoff instead`
|
|
1407
|
+
: `boundary '${checkpointRef}' spans multiple Threads; write a fresh handoff instead`);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
// Nonshrinking guard, priced by the SAME source-replayed measurement
|
|
1411
|
+
// the timeline shows: the seed's retained cost is the SOURCE's own
|
|
1412
|
+
// token count scaled by the anchor share — a small current generation
|
|
1413
|
+
// never disguises a large ancestor seed. An unmeasurable source fails
|
|
1414
|
+
// closed: pricing the unknown as zero would wave an oversized seed
|
|
1415
|
+
// through.
|
|
1416
|
+
const limits = await this.routeLimitsForAgent(agent);
|
|
1417
|
+
const handoffAt = limits?.handoffAt ?? CONTEXT_HANDOFF_AT_CAP;
|
|
1418
|
+
const sourceUsage = await this.sourceUsageTokens(sessionId, sourceIsCurrent, agent);
|
|
1419
|
+
if (sourceUsage === undefined) {
|
|
1420
|
+
throw new Error(`checkpoint '${checkpointRef}' could not be priced: its source Session's context cost cannot be measured; write a fresh handoff instead`);
|
|
1421
|
+
}
|
|
1422
|
+
const retained = this.retainedEstimate(sourceUsage, events.length, anchorTurnEndSeq);
|
|
1423
|
+
if (prefix.length >= events.length) {
|
|
1424
|
+
throw new Error('checkpoint return does not shrink the working set; use a fresh handoff instead');
|
|
1425
|
+
}
|
|
1426
|
+
if (retained >= handoffAt) {
|
|
1427
|
+
throw new Error('checkpoint return would retain a context at or above the handoff budget; use a fresh handoff instead');
|
|
1428
|
+
}
|
|
1429
|
+
// Single-Thread coverage guard: with more than one active Claim the
|
|
1430
|
+
// Host cannot prove a rewind stays inside one Thread's context.
|
|
1431
|
+
if (this.requireLedger().activeClaimCountForMember(memberId) > 1) {
|
|
1432
|
+
throw new Error('multiple active Claims: write a fresh handoff covering all of them instead of returning to a checkpoint');
|
|
1433
|
+
}
|
|
1434
|
+
return { checkpointRef, sourceSessionId: sessionId, sourceThroughSeq: SessionLogOffset(throughSeq), prefix };
|
|
1435
|
+
}
|
|
1436
|
+
sessionId = parentSession;
|
|
1437
|
+
}
|
|
1438
|
+
throw new Error(`checkpoint '${checkpointRef}' does not resolve in this Member's lineage`);
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Rebuild the handoff for a Member whose rollover committed but whose new
|
|
1442
|
+
* Session activated without the handoff delivery (a crash between the
|
|
1443
|
+
* ledger commit and the swap's delivery step). The previous Session —
|
|
1444
|
+
* recorded in the operation, mirrored by the lineage parent, and available
|
|
1445
|
+
* from the ledger even when the new Session's own header never carried it —
|
|
1446
|
+
* holds the durable intent; fold it cold and deliver the same handoff
|
|
1447
|
+
* envelope first. Idempotent: once any handoff exists in the new Session's
|
|
1448
|
+
* own log this never runs.
|
|
1449
|
+
*/
|
|
1450
|
+
async reconstructMissingHandoff(member, agent) {
|
|
1451
|
+
const previousSessionId = agent.session.header.parentSession
|
|
1452
|
+
?? this.requireLedger().previousSessionForMember(member.memberId);
|
|
1453
|
+
if (previousSessionId === undefined)
|
|
1454
|
+
return;
|
|
1455
|
+
let inspection;
|
|
1456
|
+
try {
|
|
1457
|
+
const handle = await this.ctx.sessionPersistence.open(previousSessionId, 'read');
|
|
1458
|
+
try {
|
|
1459
|
+
inspection = { events: (await handle.read()).events, inheritedEventCount: handle.inheritedEventCount };
|
|
1460
|
+
}
|
|
1461
|
+
finally {
|
|
1462
|
+
await handle.close();
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
catch (error) {
|
|
1466
|
+
this.ctx.logger.warn(`agent-team: rollover handoff reconstruction could not read the previous Session '${previousSessionId}': ${error instanceof Error ? error.message : String(error)}`);
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
const state = foldContextProjection(inspection.events, inspection.inheritedEventCount, previousSessionId);
|
|
1470
|
+
if (state.pending === null)
|
|
1471
|
+
return;
|
|
1472
|
+
const pending = state.pending;
|
|
1473
|
+
const message = createHandoffMessage({
|
|
1474
|
+
handoff: pending.handoff,
|
|
1475
|
+
previousSessionId,
|
|
1476
|
+
newSessionId: agent.session.id,
|
|
1477
|
+
trigger: 'model',
|
|
1478
|
+
handoffEventSeq: pending.resultSeq,
|
|
1479
|
+
...(pending.checkpointRef === undefined ? {} : { checkpointRef: pending.checkpointRef }),
|
|
1480
|
+
...(pending.relatedFiles.length === 0 ? {} : { relatedFiles: pending.relatedFiles }),
|
|
1481
|
+
});
|
|
1482
|
+
agent.steer(message);
|
|
1483
|
+
this.ctx.logger.info(`agent-team: reconstructed the rollover handoff for member '${this.memberLabel(member.memberId)}' from the previous Session '${previousSessionId}'`);
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Resolve the exact recorded seed prefix of one committed checkpoint
|
|
1487
|
+
* return for crash recovery: cold-read the recorded source Session, take
|
|
1488
|
+
* the contiguous prefix of the recorded exclusive length, and verify the
|
|
1489
|
+
* source's own fold still resolves the recorded anchor there — an explicit
|
|
1490
|
+
* checkpoint through `checkpointByRef`, or a default Team boundary whose
|
|
1491
|
+
* recorded ref appears among the source's own boundary keys.
|
|
1492
|
+
* Fail-closed by contract: an unreadable source or an unprovable anchor
|
|
1493
|
+
* REJECTS the activation — a committed checkpoint return may never
|
|
1494
|
+
* downgrade to a blank child.
|
|
1495
|
+
*/
|
|
1496
|
+
async recordedCheckpointPrefix(seed) {
|
|
1497
|
+
let inspection;
|
|
1498
|
+
try {
|
|
1499
|
+
const handle = await this.ctx.sessionPersistence.open(seed.sourceSessionId, 'read');
|
|
1500
|
+
try {
|
|
1501
|
+
inspection = { events: (await handle.read()).events, inheritedEventCount: handle.inheritedEventCount };
|
|
1502
|
+
}
|
|
1503
|
+
finally {
|
|
1504
|
+
await handle.close();
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
catch (error) {
|
|
1508
|
+
throw new Error(`the recorded checkpoint-return seed source Session '${seed.sourceSessionId}' is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
1509
|
+
}
|
|
1510
|
+
const through = Number(seed.sourceThroughSeq);
|
|
1511
|
+
if (!Number.isSafeInteger(through) || through < 0 || through > inspection.events.length) {
|
|
1512
|
+
throw new Error(`the recorded checkpoint-return seed cut ${through} is not a valid prefix of Session '${seed.sourceSessionId}'`);
|
|
1513
|
+
}
|
|
1514
|
+
const state = foldContextProjection(inspection.events, inspection.inheritedEventCount, seed.sourceSessionId);
|
|
1515
|
+
const anchorProven = checkpointByRef(state, seed.checkpointRef) !== undefined
|
|
1516
|
+
|| state.boundaries.some(boundary => boundary.key === seed.checkpointRef);
|
|
1517
|
+
if (!anchorProven) {
|
|
1518
|
+
throw new Error(`the recorded checkpoint-return anchor '${seed.checkpointRef}' no longer resolves in Session '${seed.sourceSessionId}'`);
|
|
1519
|
+
}
|
|
1520
|
+
return { prefix: inspection.events.slice(0, through) };
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Redeliver the old generation's unconsumed post-intent input to a
|
|
1524
|
+
* generation whose durable rollover committed but whose delivery did not
|
|
1525
|
+
* finish. Bound to the ledger's recorded transition target — the CURRENT
|
|
1526
|
+
* Session being that target — never to whether the handoff itself landed,
|
|
1527
|
+
* so a crash after the handoff but before the carried enqueue still
|
|
1528
|
+
* replays. The fold is the same durable truth the live transition uses
|
|
1529
|
+
* (unconsumed, non-Team, order preserved). Idempotency keys on what is
|
|
1530
|
+
* CURRENTLY present: delivered `user/message` ids plus the live inbox's
|
|
1531
|
+
* pending next-step/next-turn ids — a historical insert that was already
|
|
1532
|
+
* claimed (and removed) but never surfaced is NOT known and must replay.
|
|
1533
|
+
* Fail-closed for genuinely unreadable previous Sessions (missing/IO) so
|
|
1534
|
+
* the Member's input is never dropped silently, with three bounded
|
|
1535
|
+
* exceptions: a generation that already started its own turns needs no
|
|
1536
|
+
* replay (its carried input was delivered or superseded while it ran), a
|
|
1537
|
+
* log-corruption class error skips with a warning (the Host repairs torn
|
|
1538
|
+
* tails; a retired generation's corrupt log must not permanently block the
|
|
1539
|
+
* Member's activation), and a deterministic released-format refusal skips
|
|
1540
|
+
* with a warning (the candidate's own migration audit refuses that
|
|
1541
|
+
* artifact, so no retry can ever read it).
|
|
1542
|
+
*/
|
|
1543
|
+
async replayCarriedInput(member, agent, generationStarted) {
|
|
1544
|
+
const transition = this.requireLedger().lastTransitionForMember(member.memberId);
|
|
1545
|
+
if (transition === undefined || transition.targetSessionId !== agent.session.id)
|
|
1546
|
+
return 0;
|
|
1547
|
+
// A generation that already started its own turns needs no previous-Session
|
|
1548
|
+
// replay: its carried input was delivered with the handoff (or superseded)
|
|
1549
|
+
// before any own turn could run. Skipping the inspect also keeps a later
|
|
1550
|
+
// corruption or loss of the retired Session from re-blocking this Member
|
|
1551
|
+
// on every restart of the current generation.
|
|
1552
|
+
if (generationStarted) {
|
|
1553
|
+
this.ctx.logger.info(`agent-team: skipping carried-input replay for member '${this.memberLabel(member.memberId)}': the current Session '${agent.session.id}' already started a generation`);
|
|
1554
|
+
return 0;
|
|
1555
|
+
}
|
|
1556
|
+
let inspectionEvents;
|
|
1557
|
+
let inspectionInherited;
|
|
1558
|
+
try {
|
|
1559
|
+
const handle = await this.ctx.sessionPersistence.open(transition.previousSessionId, 'read');
|
|
1560
|
+
try {
|
|
1561
|
+
inspectionEvents = (await handle.read()).events;
|
|
1562
|
+
inspectionInherited = handle.inheritedEventCount;
|
|
1563
|
+
}
|
|
1564
|
+
finally {
|
|
1565
|
+
await handle.close();
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
catch (error) {
|
|
1569
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1570
|
+
// Log-corruption class: bounded fail-open. The current Session's own
|
|
1571
|
+
// fold is intact and the Host repairs the retired log's torn tail; a
|
|
1572
|
+
// corrupted retired generation must not permanently block activation.
|
|
1573
|
+
if (/corrupt session log/.test(detail)) {
|
|
1574
|
+
this.ctx.logger.warn(`agent-team: previous Session '${transition.previousSessionId}' holding carried input for member '${this.memberLabel(member.memberId)}' is corrupt: ${detail}; skipping the replay`);
|
|
1575
|
+
return 0;
|
|
1576
|
+
}
|
|
1577
|
+
// Deterministic released-format refusal: the retired artifact is refused
|
|
1578
|
+
// by the candidate's own migration audit, which no retry can change.
|
|
1579
|
+
// Failing activation here would turn a data problem in a Session the
|
|
1580
|
+
// Member no longer runs in into permanent unavailability — the exact
|
|
1581
|
+
// failure class this hardening removes. Genuine IO and unknown failures
|
|
1582
|
+
// stay fail-closed so carried input is never dropped silently.
|
|
1583
|
+
if (error instanceof SessionFormatUnsupportedError) {
|
|
1584
|
+
this.ctx.logger.warn(`agent-team: previous Session '${transition.previousSessionId}' holding carried input for member '${this.memberLabel(member.memberId)}' is refused by the session-format migration: ${detail}; skipping the replay`);
|
|
1585
|
+
return 0;
|
|
1586
|
+
}
|
|
1587
|
+
throw new Error(`the previous Session '${transition.previousSessionId}' holding the Member's carried input is unreadable: ${detail}`);
|
|
1588
|
+
}
|
|
1589
|
+
const state = foldContextProjection(inspectionEvents, inspectionInherited, transition.previousSessionId);
|
|
1590
|
+
const carried = carriedInputOf(state);
|
|
1591
|
+
if (carried.length === 0)
|
|
1592
|
+
return 0;
|
|
1593
|
+
const known = new Set();
|
|
1594
|
+
for (const event of agent.session.ownEvents()) {
|
|
1595
|
+
if (event.type !== 'user/message')
|
|
1596
|
+
continue;
|
|
1597
|
+
const id = event.data.id;
|
|
1598
|
+
if (id !== undefined)
|
|
1599
|
+
known.add(id);
|
|
1600
|
+
}
|
|
1601
|
+
for (const pending of [...agent.inbox.nextStep, ...agent.inbox.nextTurn]) {
|
|
1602
|
+
known.add(pending.id);
|
|
1603
|
+
}
|
|
1604
|
+
let redelivered = 0;
|
|
1605
|
+
for (const message of carried) {
|
|
1606
|
+
if (known.has(message.id))
|
|
1607
|
+
continue;
|
|
1608
|
+
agent.followup(message);
|
|
1609
|
+
redelivered += 1;
|
|
1610
|
+
}
|
|
1611
|
+
if (redelivered > 0)
|
|
1612
|
+
this.ctx.logger.info(`agent-team: redelivered ${redelivered} carried input message(s) for member '${this.memberLabel(member.memberId)}' after the rollover crash recovery`);
|
|
1613
|
+
return redelivered;
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Agent-only checkpoint request validation: the tool calls this inside its
|
|
1617
|
+
* own running turn. Like `context_rollover`, the tool performs no side effect —
|
|
1618
|
+
* the durable checkpoint is the successful `tool/call`+`tool/result` pair
|
|
1619
|
+
* the Session projection folds; the ref returned here is deterministic
|
|
1620
|
+
* from this Member Session's identity plus the tool call id, so the model
|
|
1621
|
+
* can cite it before the result exists and a repeated provider call id in
|
|
1622
|
+
* another generation never collides with this one.
|
|
1623
|
+
*/
|
|
1624
|
+
recordCheckpointForAgent(agent, request) {
|
|
1625
|
+
const member = this.memberForAgent(agent);
|
|
1626
|
+
if (member === undefined || member.state !== 'enabled')
|
|
1627
|
+
throw new Error('context_checkpoint requires an active Team Member');
|
|
1628
|
+
if (this.runningAgents.has(agent.id) !== true)
|
|
1629
|
+
throw new Error('context_checkpoint must run inside this Member\'s own running turn');
|
|
1630
|
+
const name = request.name.trim();
|
|
1631
|
+
if (name === '')
|
|
1632
|
+
throw new Error('context_checkpoint requires a non-empty name');
|
|
1633
|
+
if (name.length > MAX_CHECKPOINT_NAME_CHARS)
|
|
1634
|
+
throw new Error(`context_checkpoint name exceeds ${MAX_CHECKPOINT_NAME_CHARS} characters`);
|
|
1635
|
+
return { checkpointRef: checkpointRefFor(agent.session.id, request.callId), name };
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Agent-only bounded structural timeline: resolved checkpoints plus Team
|
|
1639
|
+
* delivery, handoff, and compaction boundaries across the current
|
|
1640
|
+
* generation and its archived ancestor lineage. Structural only — no
|
|
1641
|
+
* transcript content. The meter prices retained/discarded tokens; entries
|
|
1642
|
+
* the Host cannot prove restorable carry a rejection reason instead of
|
|
1643
|
+
* silently disappearing.
|
|
1644
|
+
*/
|
|
1645
|
+
async contextTimelineForAgent(agent, request) {
|
|
1646
|
+
const member = this.memberForAgent(agent);
|
|
1647
|
+
if (member === undefined || member.state !== 'enabled')
|
|
1648
|
+
throw new Error('context_timeline requires an active Team Member');
|
|
1649
|
+
const limit = request.limit === undefined ? DEFAULT_TIMELINE_LIMIT : Math.trunc(request.limit);
|
|
1650
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_TIMELINE_LIMIT)
|
|
1651
|
+
throw new Error(`context_timeline limit must be between 1 and ${MAX_TIMELINE_LIMIT}`);
|
|
1652
|
+
const meter = agent.ctx.get('tokenMeter');
|
|
1653
|
+
const measurement = meter?.measure(agent.session);
|
|
1654
|
+
const usageTokens = measurement?.totalTokens ?? 0;
|
|
1655
|
+
const limits = await this.routeLimitsForAgent(agent);
|
|
1656
|
+
const hardLimit = limits?.hardLimit ?? CONTEXT_HARD_LIMIT_CAP;
|
|
1657
|
+
const handoffAt = limits?.handoffAt ?? CONTEXT_HANDOFF_AT_CAP;
|
|
1658
|
+
// Fold the current generation, then walk archived ancestors through their
|
|
1659
|
+
// persisted logs; the same projection definition folds every source.
|
|
1660
|
+
const items = [];
|
|
1661
|
+
const seen = new Set();
|
|
1662
|
+
let sessionId = member.sessionId;
|
|
1663
|
+
let live = true;
|
|
1664
|
+
let guard = 0;
|
|
1665
|
+
while (sessionId !== undefined && guard++ < MAX_TIMELINE_ANCESTORS) {
|
|
1666
|
+
let state;
|
|
1667
|
+
let sourceSessionId = sessionId;
|
|
1668
|
+
let sourceEvents = [];
|
|
1669
|
+
// Capture this iteration's source identity BEFORE the branch advances
|
|
1670
|
+
// the lineage flag: measurement and discard pricing both key on it.
|
|
1671
|
+
const sourceIsCurrent = live;
|
|
1672
|
+
if (live) {
|
|
1673
|
+
state = foldContextProjection(agent.session.ownEvents(), agent.session.inheritedEventCount, agent.session.id);
|
|
1674
|
+
sourceEvents = agent.session.snapshotEvents();
|
|
1675
|
+
sessionId = agent.session.header.parentSession;
|
|
1676
|
+
live = false;
|
|
1677
|
+
}
|
|
1678
|
+
else {
|
|
1679
|
+
try {
|
|
1680
|
+
const handle = await this.ctx.sessionPersistence.open(sessionId, 'read');
|
|
1681
|
+
try {
|
|
1682
|
+
const { events } = await handle.read();
|
|
1683
|
+
state = foldContextProjection(events, handle.inheritedEventCount, sessionId);
|
|
1684
|
+
sourceEvents = events;
|
|
1685
|
+
sourceSessionId = sessionId;
|
|
1686
|
+
sessionId = handle.header.parentSession;
|
|
1687
|
+
}
|
|
1688
|
+
finally {
|
|
1689
|
+
await handle.close();
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
catch {
|
|
1693
|
+
// An unreadable ancestor ends the lineage walk here.
|
|
1694
|
+
sessionId = undefined;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
if (state === undefined)
|
|
1698
|
+
break;
|
|
1699
|
+
// Price every candidate of this source against the SOURCE's own
|
|
1700
|
+
// replayed measurement — a small current generation never shrinks a
|
|
1701
|
+
// large ancestor's real seed cost. An unmeasurable source (no meter,
|
|
1702
|
+
// unreadable ancestor) prices as UNKNOWN, never as zero: the timeline
|
|
1703
|
+
// marks its candidates unprovable and the return guard rejects them.
|
|
1704
|
+
const sourceUsage = await this.sourceUsageTokens(sourceSessionId, sourceIsCurrent, agent);
|
|
1705
|
+
for (const candidate of timelineCandidates(state, limit)) {
|
|
1706
|
+
if (seen.has(candidate.ref))
|
|
1707
|
+
continue;
|
|
1708
|
+
seen.add(candidate.ref);
|
|
1709
|
+
items.push(this.timelineItemFor(candidate, sourceUsage, usageTokens, hardLimit, handoffAt, sourceSessionId, sourceIsCurrent, sourceEvents));
|
|
1710
|
+
if (items.length >= limit)
|
|
1711
|
+
break;
|
|
1712
|
+
}
|
|
1713
|
+
if (items.length >= limit)
|
|
1714
|
+
break;
|
|
1715
|
+
}
|
|
1716
|
+
return { usageTokens, hardLimit, handoffAt, items };
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Replayed measurement of one lineage source: the live current Session
|
|
1720
|
+
* measures directly; an archived ancestor measures through a borrowed
|
|
1721
|
+
* prepared Session, so a seed's retained cost is priced in the SOURCE's
|
|
1722
|
+
* own tokens — never the current generation's. Returns undefined when no
|
|
1723
|
+
* meter is available or the source cannot be borrowed; callers fail
|
|
1724
|
+
* closed on the unknown.
|
|
1725
|
+
*/
|
|
1726
|
+
async sourceUsageTokens(sessionId, live, agent) {
|
|
1727
|
+
const meter = agent.ctx.get('tokenMeter');
|
|
1728
|
+
if (meter === undefined)
|
|
1729
|
+
return undefined;
|
|
1730
|
+
if (live)
|
|
1731
|
+
return meter.measure(agent.session)?.totalTokens;
|
|
1732
|
+
try {
|
|
1733
|
+
// 0.1.5 removed borrowSession: rebuild a detached Session from the
|
|
1734
|
+
// stored log so the seed's retained cost is priced by the SOURCE's own
|
|
1735
|
+
// replay, never the current generation's.
|
|
1736
|
+
const handle = await this.ctx.sessionPersistence.open(sessionId, 'read');
|
|
1737
|
+
try {
|
|
1738
|
+
const { events } = await handle.read();
|
|
1739
|
+
const session = Session.create(sessionId, events, handle.header, handle.inheritedEventCount);
|
|
1740
|
+
return meter.measure(session)?.totalTokens;
|
|
1741
|
+
}
|
|
1742
|
+
finally {
|
|
1743
|
+
await handle.close();
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
catch {
|
|
1747
|
+
return undefined;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Monotonic anchor-share estimate of a seed's retained cost, priced in the
|
|
1752
|
+
* SOURCE Session's own measurement: the fraction of the source log the
|
|
1753
|
+
* seed prefix covers, scaled to the source's replayed token count. The
|
|
1754
|
+
* anchor position is exact and the share grows monotonically toward the
|
|
1755
|
+
* source's head (100%). A large ancestor's anchor therefore prices at the
|
|
1756
|
+
* ancestor's real size even inside a small current generation — the
|
|
1757
|
+
* timeline display and the return guard share this one estimate.
|
|
1758
|
+
*/
|
|
1759
|
+
retainedEstimate(sourceUsageTokens, sourceLength, anchorTurnEndSeq) {
|
|
1760
|
+
if (sourceLength <= 0)
|
|
1761
|
+
return sourceUsageTokens;
|
|
1762
|
+
const share = Math.min(1, Math.max(0, (anchorTurnEndSeq + 1) / sourceLength));
|
|
1763
|
+
return Math.round(sourceUsageTokens * share);
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Threads whose facts entered this Session's model context by the given
|
|
1767
|
+
* seq: delivered Team notices (their bodies quote `Thread: <ref>`
|
|
1768
|
+
* structurally) and successful Team-claim mutations (their task overlays
|
|
1769
|
+
* resolve to Threads through the ledger). Never from unread ledger
|
|
1770
|
+
* activity — a Thread the Member never saw did not enter its context.
|
|
1771
|
+
* Order-stable, deduplicated.
|
|
1772
|
+
*/
|
|
1773
|
+
threadsEnteringContext(events, throughSeq) {
|
|
1774
|
+
const refs = [];
|
|
1775
|
+
const openAttributions = new Map();
|
|
1776
|
+
const push = (ref) => {
|
|
1777
|
+
if (ref !== undefined && !refs.includes(ref))
|
|
1778
|
+
refs.push(ref);
|
|
1779
|
+
};
|
|
1780
|
+
for (const event of events) {
|
|
1781
|
+
if (event.seq > throughSeq)
|
|
1782
|
+
break;
|
|
1783
|
+
if (event.type === 'tool/call' && (event.data.name === 'team_claim' || event.data.name === 'team_message')) {
|
|
1784
|
+
openAttributions.set(event.data.callId, { name: event.data.name, arguments: event.data.arguments });
|
|
1785
|
+
}
|
|
1786
|
+
else if (event.type === 'tool/result') {
|
|
1787
|
+
const block = event.data.message.content?.[0];
|
|
1788
|
+
if (block !== undefined && block.toolCallId !== undefined) {
|
|
1789
|
+
const recorded = openAttributions.get(block.toolCallId);
|
|
1790
|
+
if (recorded !== undefined && block.isError !== true) {
|
|
1791
|
+
openAttributions.delete(block.toolCallId);
|
|
1792
|
+
try {
|
|
1793
|
+
const args = JSON.parse(recorded.arguments);
|
|
1794
|
+
// A claim mutation resolves its Task overlay through the ledger;
|
|
1795
|
+
// a team_message committed reply names its Thread in its call
|
|
1796
|
+
// arguments; a committed start's Thread is born in the result
|
|
1797
|
+
// and is read from the durable presentation meta.
|
|
1798
|
+
if (recorded.name === 'team_claim' && typeof args.taskRef === 'string' && args.taskRef !== '') {
|
|
1799
|
+
push(this.requireLedger().threadForTask(args.taskRef));
|
|
1800
|
+
}
|
|
1801
|
+
else if (recorded.name === 'team_message') {
|
|
1802
|
+
const meta = event.data.meta;
|
|
1803
|
+
if (meta !== undefined && typeof meta === 'object' && meta.kind === 'committed') {
|
|
1804
|
+
const metaThreadRef = meta.threadRef;
|
|
1805
|
+
if (typeof metaThreadRef === 'string' && metaThreadRef !== '')
|
|
1806
|
+
push(metaThreadRef);
|
|
1807
|
+
}
|
|
1808
|
+
else if (typeof args.threadRef === 'string' && args.threadRef !== '') {
|
|
1809
|
+
push(args.threadRef);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
catch {
|
|
1814
|
+
// Malformed call arguments contribute no attribution.
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
else if (event.type === 'user/message') {
|
|
1820
|
+
const source = event.data.source;
|
|
1821
|
+
if (source?.kind !== 'plugin')
|
|
1822
|
+
continue;
|
|
1823
|
+
// Reminder notices never enter attribution — a progress nudge or a
|
|
1824
|
+
// recovery instruction is not a Team fact.
|
|
1825
|
+
if (source.form === 'notice' && source.summary !== undefined && isReminderNoticeSummary(source.summary))
|
|
1826
|
+
continue;
|
|
1827
|
+
const text = event.data.content.filter(block => block.type === 'text').map(block => block.text ?? '').join('\n');
|
|
1828
|
+
for (const match of text.matchAll(/Thread: (thread:[0-9a-f-]{6,})/g)) {
|
|
1829
|
+
push(match[1]);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return refs;
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Price and annotate one timeline candidate without mutating anything.
|
|
1837
|
+
* Retained prices in the SOURCE Session's own replayed measurement (the
|
|
1838
|
+
* monotonic anchor-share of the source log); discarded is what a return
|
|
1839
|
+
* replaces — for a current-generation anchor, the measured usage beyond
|
|
1840
|
+
* the anchor; for an ancestor anchor, the current generation's whole
|
|
1841
|
+
* usage (an approximation: the ancestor's own suffix is not part of this
|
|
1842
|
+
* generation). A small current child therefore discards little but may
|
|
1843
|
+
* still retain a large ancestor seed, and both numbers say so honestly.
|
|
1844
|
+
*/
|
|
1845
|
+
timelineItemFor(candidate, sourceUsage, currentUsage, _hardLimit, handoffAt, sourceSessionId, sourceIsCurrent, sourceEvents) {
|
|
1846
|
+
const retainedTokens = candidate.source === 'head'
|
|
1847
|
+
? currentUsage
|
|
1848
|
+
: this.retainedEstimate(sourceUsage ?? 0, sourceEvents.length, candidate.turnEndSeq);
|
|
1849
|
+
const discardedTokens = candidate.source === 'head'
|
|
1850
|
+
? 0
|
|
1851
|
+
: sourceIsCurrent
|
|
1852
|
+
? Math.max(0, currentUsage - retainedTokens)
|
|
1853
|
+
: currentUsage;
|
|
1854
|
+
const affectedThreads = candidate.turnEndSeq === -1 ? [] : this.threadsEnteringContext(sourceEvents, candidate.turnEndSeq);
|
|
1855
|
+
let restorable = candidate.turnEndSeq !== -1;
|
|
1856
|
+
let reason;
|
|
1857
|
+
if (candidate.source === 'head') {
|
|
1858
|
+
// The head is the current working set: returning to it discards
|
|
1859
|
+
// nothing and is never a meaningful return target.
|
|
1860
|
+
restorable = false;
|
|
1861
|
+
reason = 'the head is the current working set; returning to it discards nothing';
|
|
1862
|
+
}
|
|
1863
|
+
else if (sourceUsage === undefined) {
|
|
1864
|
+
// The source's cost cannot be measured (no meter, or the archived
|
|
1865
|
+
// ancestor cannot be borrowed): the budget cannot be proven, so the
|
|
1866
|
+
// candidate is not selectable. Never price an unknown as zero.
|
|
1867
|
+
restorable = false;
|
|
1868
|
+
reason = 'the source Session\'s context cost cannot be measured, so the return budget cannot be proven';
|
|
1869
|
+
}
|
|
1870
|
+
else if (candidate.source === 'handoff' || candidate.source === 'compaction') {
|
|
1871
|
+
// A handoff starts a generation and a compaction rewrites the visible
|
|
1872
|
+
// surface: rewinding into them is not a proven-safe V1 target.
|
|
1873
|
+
restorable = false;
|
|
1874
|
+
reason = `source '${candidate.source}' is not a restorable checkpoint`;
|
|
1875
|
+
}
|
|
1876
|
+
else if (candidate.source === 'team-boundary') {
|
|
1877
|
+
// A Team delivery is a selectable default checkpoint exactly when the
|
|
1878
|
+
// Host can prove it stays inside one Thread's context: the boundary
|
|
1879
|
+
// resolved at a completed turn AND exactly one Thread's facts entered
|
|
1880
|
+
// the Session context through it. Multi-Thread or unattributable
|
|
1881
|
+
// boundaries document why they are not selectable.
|
|
1882
|
+
if (affectedThreads.length !== 1) {
|
|
1883
|
+
restorable = false;
|
|
1884
|
+
reason = affectedThreads.length === 0
|
|
1885
|
+
? 'no single Thread is attributable to this boundary'
|
|
1886
|
+
: 'multiple Threads entered the context through this boundary; write a fresh handoff instead';
|
|
1887
|
+
}
|
|
1888
|
+
else if (retainedTokens >= handoffAt) {
|
|
1889
|
+
restorable = false;
|
|
1890
|
+
reason = 'retained context would not materially shrink the working set';
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
else if (candidate.source === 'agent' && retainedTokens >= handoffAt) {
|
|
1894
|
+
restorable = false;
|
|
1895
|
+
reason = 'retained context would be at or above the handoff budget';
|
|
1896
|
+
}
|
|
1897
|
+
return {
|
|
1898
|
+
checkpointRef: candidate.ref,
|
|
1899
|
+
name: candidate.label,
|
|
1900
|
+
source: candidate.source,
|
|
1901
|
+
retainedTokens,
|
|
1902
|
+
discardedTokens,
|
|
1903
|
+
affectedThreads,
|
|
1904
|
+
restorable,
|
|
1905
|
+
...(reason === undefined ? {} : { reason }),
|
|
1906
|
+
...(candidate.source === 'agent' ? {} : { sourceSessionId }),
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Agent-only rollover request validation: the tool calls this to check its
|
|
1911
|
+
* Member binding, exclusivity, and checkpoint ownership. It performs no
|
|
1912
|
+
* lifecycle effect — the actual transition reacts to the successful tool
|
|
1913
|
+
* result through the context-management coordinator.
|
|
1914
|
+
*/
|
|
1915
|
+
async requestNewContext(agent, request) {
|
|
1916
|
+
const member = this.memberForAgent(agent);
|
|
1917
|
+
if (member === undefined || member.state !== 'enabled')
|
|
1918
|
+
throw new Error('context_rollover requires an active Team Member');
|
|
1919
|
+
if (this.contextManagement.isTransitioning(member.memberId))
|
|
1920
|
+
throw new Error('a context rollover is already scheduled for this Member; wait for it to finish before requesting another');
|
|
1921
|
+
if (this.runningAgents.has(agent.id) !== true) {
|
|
1922
|
+
// The tool runs inside the Member's own turn, so a non-running agent at
|
|
1923
|
+
// this point is a harness anomaly; refuse rather than schedule a swap
|
|
1924
|
+
// outside the turn fence.
|
|
1925
|
+
throw new Error('context_rollover must run inside this Member\'s own running turn');
|
|
1926
|
+
}
|
|
1927
|
+
// Job ownership guard: disposing the old Agent cancels its running jobs
|
|
1928
|
+
// and orphaned terminal-but-unreported output would vanish with it. The
|
|
1929
|
+
// rejection names the jobs so the model can collect or stop them first;
|
|
1930
|
+
// the transition rechecks at the lifecycle commit seam because a job may
|
|
1931
|
+
// settle between this validation and the swap.
|
|
1932
|
+
const blocking = this.ownedJobsBlockingRollover(agent);
|
|
1933
|
+
if (blocking.length > 0) {
|
|
1934
|
+
throw new Error(`context_rollover is refused while this Member owns jobs that would not survive the switch (${blocking.join(', ')}); collect or stop them first, then retry`);
|
|
1935
|
+
}
|
|
1936
|
+
if (request.checkpointRef === undefined)
|
|
1937
|
+
return { mode: 'fresh' };
|
|
1938
|
+
if (!/^(context-checkpoint-[0-9a-f]{64}|team-boundary-[0-9a-f]{64})$/.test(request.checkpointRef))
|
|
1939
|
+
throw new Error('checkpointRef must be an opaque ref exactly as returned by context_timeline');
|
|
1940
|
+
// Full current-state prevalidation through the ONE resolver the swap
|
|
1941
|
+
// itself uses: a ref that is fabricated, unattributable, nonshrinking,
|
|
1942
|
+
// unmeasurable, over-budget, or blocked by multiple active Claims
|
|
1943
|
+
// rejects HERE — a model-visible error result instead of a fake
|
|
1944
|
+
// `scheduled` whose async swap always fails. The seed is resolved and
|
|
1945
|
+
// discarded: the transition seam resolves it again, so only the mutable
|
|
1946
|
+
// guard set (jobs, route limits, lineage growth) is revalidated there.
|
|
1947
|
+
await this.resolveCheckpointSeed(member.memberId, agent, request.checkpointRef);
|
|
1948
|
+
return { mode: 'from-checkpoint' };
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* Jobs this Agent owns that cannot survive a generation swap: any
|
|
1952
|
+
* running/stopping job, and any settled job whose terminal output was
|
|
1953
|
+
* never reported (disposal would silently discard it). In-place hard
|
|
1954
|
+
* compaction is exempt — it never cancels the owner.
|
|
1955
|
+
*/
|
|
1956
|
+
ownedJobsBlockingRollover(agent) {
|
|
1957
|
+
const jobs = agent.ctx.get('jobs');
|
|
1958
|
+
if (jobs === undefined)
|
|
1959
|
+
return [];
|
|
1960
|
+
return jobs.list(agent)
|
|
1961
|
+
.filter((job) => job.status === 'running' || job.status === 'stopping' || ((job.status === 'completed' || job.status === 'killed' || job.status === 'failed') && !job.reported))
|
|
1962
|
+
.map((job) => `${job.label} (${job.id})`);
|
|
1963
|
+
}
|
|
1964
|
+
/**
|
|
1965
|
+
* Agent-only session rollover commit: the Member actor must be the target
|
|
1966
|
+
* Member on its currently bound live Session. The Host performs the actual
|
|
1967
|
+
* generation swap around this write; the ledger records only the durable
|
|
1968
|
+
* binding transition and rollover audit envelope.
|
|
1969
|
+
*/
|
|
1970
|
+
async rolloverSessionForAgent(agent, request) {
|
|
1971
|
+
const actor = this.memberCall(agent, request.workspaceId);
|
|
1972
|
+
const result = await this.requireLedger().rolloverMemberSession({ ...request, actor });
|
|
1973
|
+
if (result.committed)
|
|
1974
|
+
this.emitCommitted(result.value.receipt);
|
|
1975
|
+
return result.value;
|
|
1976
|
+
}
|
|
1009
1977
|
emitCommittedOutcome(result) {
|
|
1010
1978
|
if (result.committed && result.value.kind === 'committed' && result.value.receipt !== undefined)
|
|
1011
1979
|
this.emitCommitted(result.value.receipt);
|
|
@@ -1084,16 +2052,40 @@ let AgentTeam = (() => {
|
|
|
1084
2052
|
throw error;
|
|
1085
2053
|
}
|
|
1086
2054
|
}
|
|
1087
|
-
|
|
2055
|
+
/**
|
|
2056
|
+
* Session headers currently durable in the persistence backend.
|
|
2057
|
+
*
|
|
2058
|
+
* The Host retires a disposed Session's log without awaiting it, so a
|
|
2059
|
+
* concurrent activation can observe the JSONL backend's transient win32
|
|
2060
|
+
* staging directories (.dsh-mkdir-*) as ENOENT while they rename into
|
|
2061
|
+
* place. The read is idempotent; back off briefly instead of failing the
|
|
2062
|
+
* activation on a race the publisher resolves within milliseconds.
|
|
2063
|
+
*/
|
|
2064
|
+
async persistedSessionHeaders() {
|
|
2065
|
+
for (let attempt = 0;; attempt += 1) {
|
|
2066
|
+
try {
|
|
2067
|
+
return await this.ctx.sessionPersistence.list();
|
|
2068
|
+
}
|
|
2069
|
+
catch (error) {
|
|
2070
|
+
if (attempt >= 3 || error?.code !== 'ENOENT')
|
|
2071
|
+
throw error;
|
|
2072
|
+
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 25));
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
async activateMember(member, knownWorkspacePath, knownSessions, forkedFrom, options) {
|
|
1088
2077
|
if (this.handles.has(member.memberId))
|
|
1089
2078
|
return;
|
|
1090
2079
|
let created;
|
|
1091
2080
|
try {
|
|
1092
2081
|
const workspace = this.requireWorkspace(member.workspaceId);
|
|
1093
2082
|
const workspacePath = knownWorkspacePath ?? workspace.path;
|
|
1094
|
-
|
|
2083
|
+
// Existing Members carry the pre-sanitization ledger path; activation
|
|
2084
|
+
// migrates it onto the sanitized directory before provisioning.
|
|
2085
|
+
const sanitizedMemoryPath = dshHomePath('agent-team', 'members', memberMemoryDirectoryName(member.memberId));
|
|
2086
|
+
await this.memberRuntime.initializePrivateMemory(sanitizedMemoryPath, member.privateMemoryPath);
|
|
1095
2087
|
const persisted = knownSessions !== undefined ? knownSessions.has(member.sessionId)
|
|
1096
|
-
:
|
|
2088
|
+
: await this.sessionPersisted(member.sessionId);
|
|
1097
2089
|
// AgentOptions declares only provider/model. Install the full selection
|
|
1098
2090
|
// through the public Agent model-selection seam so reasoning effort is
|
|
1099
2091
|
// applied to the next request and not lost during activation.
|
|
@@ -1104,16 +2096,53 @@ let AgentTeam = (() => {
|
|
|
1104
2096
|
// allow-list filters the catalog by name through the live ref below.
|
|
1105
2097
|
// `swap` is bound by the provider at activation (no-op until then).
|
|
1106
2098
|
const skillSelection = { current: member.capabilities?.skills?.allow, swap: () => { } };
|
|
1107
|
-
const setup = async (agentCtx) => {
|
|
2099
|
+
const setup = async (agentCtx, agent) => {
|
|
1108
2100
|
await this.ctx.agentPresets.mount(agentCtx, member.presetId);
|
|
1109
2101
|
this.memberRuntime.applyMemberToolPolicy(agentCtx, member);
|
|
1110
2102
|
this.validateMemberPreset(agentCtx);
|
|
1111
2103
|
installModelSelection(agentCtx, selected);
|
|
2104
|
+
// Admission gate for pending context rollovers: once a successful
|
|
2105
|
+
// context_rollover result is durable, queued input must not open another
|
|
2106
|
+
// old-generation model request. The turn-stop boundary captures the
|
|
2107
|
+
// inbox (non-Team input is carried to the new generation), and a
|
|
2108
|
+
// racing pre-step rejects instead of admitting claimed messages.
|
|
2109
|
+
agentCtx.on('agent/turn-stopping', ({ agent }) => {
|
|
2110
|
+
if (!this.contextManagement.needsAdmissionGate(agent))
|
|
2111
|
+
return;
|
|
2112
|
+
this.contextManagement.captureQueuedInput(agent);
|
|
2113
|
+
});
|
|
2114
|
+
agentCtx.on('agent/pre-step', async ({ agent, messages, signal }, next) => {
|
|
2115
|
+
// Check before AND after the waterfall: a rollover pending at either
|
|
2116
|
+
// edge must reject this old-generation step, preserving its claimed
|
|
2117
|
+
// input for the new generation instead of letting it run or drop.
|
|
2118
|
+
if (this.contextManagement.needsAdmissionGate(agent)) {
|
|
2119
|
+
this.contextManagement.captureClaimedInput(agent, messages);
|
|
2120
|
+
return { kind: 'reject' };
|
|
2121
|
+
}
|
|
2122
|
+
// Team pressure policy rides the same pre-step seam after the
|
|
2123
|
+
// admission gate: the handoff-budget notice steers into the running
|
|
2124
|
+
// turn, and the hard limit forces compaction before the request is
|
|
2125
|
+
// forwarded — failing closed blocks the step instead of submitting
|
|
2126
|
+
// over the Team limit. Missing route capacity is an explicit reject.
|
|
2127
|
+
const pressure = await this.pressurePolicy.onPreStep(agent, signal);
|
|
2128
|
+
if (pressure.kind === 'reject')
|
|
2129
|
+
return { kind: 'reject' };
|
|
2130
|
+
const decision = await next();
|
|
2131
|
+
if (decision.kind === 'reject' || !this.contextManagement.needsAdmissionGate(agent))
|
|
2132
|
+
return decision;
|
|
2133
|
+
this.contextManagement.captureClaimedInput(agent, messages);
|
|
2134
|
+
return { kind: 'reject' };
|
|
2135
|
+
});
|
|
2136
|
+
// Provider context-overflow recovery: one bounded compact-and-retry
|
|
2137
|
+
// sequence per failure chain through the Team-owned policy.
|
|
2138
|
+
agentCtx.on('agent/request-error', async ({ agent, failure, signal }, next) => {
|
|
2139
|
+
const retry = await this.pressurePolicy.onRequestError(agent, failure, signal);
|
|
2140
|
+
if (retry)
|
|
2141
|
+
return { kind: 'retry' };
|
|
2142
|
+
return next();
|
|
2143
|
+
});
|
|
1112
2144
|
return {
|
|
1113
2145
|
commit: () => {
|
|
1114
|
-
const agent = agentCtx.agent;
|
|
1115
|
-
if (agent === undefined)
|
|
1116
|
-
throw new Error('agent-team setup has no unpublished Agent');
|
|
1117
2146
|
// The member scope composes no sandbox-policy service (the preset
|
|
1118
2147
|
// owns no sandbox row), so read the last logged mode straight from
|
|
1119
2148
|
// the session log; `sandbox/mode` is log-only and never joins the
|
|
@@ -1125,13 +2154,51 @@ let AgentTeam = (() => {
|
|
|
1125
2154
|
},
|
|
1126
2155
|
};
|
|
1127
2156
|
};
|
|
2157
|
+
// A renewal's create path parents at the ledger-recorded previous
|
|
2158
|
+
// Session even when the caller does not pass one: a crash between the
|
|
2159
|
+
// durable rollover commit and this activation recreates the Session
|
|
2160
|
+
// from the ledger binding alone, and the lineage parent is what makes
|
|
2161
|
+
// the missing handoff reconstructible on the NEXT failure. A recorded
|
|
2162
|
+
// checkpoint seed re-seeds the child here — the crash lost the process
|
|
2163
|
+
// memory, but the ledger envelope names the exact source prefix.
|
|
2164
|
+
let recordedSeed;
|
|
2165
|
+
let recordedSeedCut;
|
|
2166
|
+
const rolloverSeed = !persisted && options?.seed === undefined
|
|
2167
|
+
? this.requireLedger().rolloverSeedForMember(member.memberId, member.sessionId)
|
|
2168
|
+
: undefined;
|
|
2169
|
+
if (rolloverSeed !== undefined) {
|
|
2170
|
+
// Fail-closed: an unreadable or unprovable recorded seed rejects
|
|
2171
|
+
// this activation (the catch below records the diagnostic) instead
|
|
2172
|
+
// of silently downgrading a committed checkpoint return to a blank
|
|
2173
|
+
// child.
|
|
2174
|
+
const resolved = await this.recordedCheckpointPrefix(rolloverSeed);
|
|
2175
|
+
recordedSeed = resolved.prefix;
|
|
2176
|
+
recordedSeedCut = SessionLogOffset(resolved.prefix.length);
|
|
2177
|
+
}
|
|
2178
|
+
const lineageParent = forkedFrom ?? (!persisted ? this.requireLedger().previousSessionForMember(member.memberId) : undefined);
|
|
2179
|
+
let recoveryCarried = 0;
|
|
2180
|
+
const seededFromLedger = recordedSeed !== undefined;
|
|
2181
|
+
const effectiveParent = recordedSeed !== undefined ? rolloverSeed.sourceSessionId : lineageParent;
|
|
2182
|
+
const seedOptions = options?.seed !== undefined && options.inheritedEventCount !== undefined
|
|
2183
|
+
? { seed: options.seed, inheritedEventCount: options.inheritedEventCount }
|
|
2184
|
+
: recordedSeed !== undefined && recordedSeedCut !== undefined
|
|
2185
|
+
? { seed: recordedSeed, inheritedEventCount: recordedSeedCut }
|
|
2186
|
+
: {};
|
|
1128
2187
|
created = persisted
|
|
1129
2188
|
? await this.ctx.agents.resume({ resumeSessionId: member.sessionId, agentOptions, setup })
|
|
1130
2189
|
: await this.ctx.agents.create({
|
|
1131
2190
|
sessionId: member.sessionId,
|
|
1132
2191
|
// A context renewal records its fork lineage so the archived
|
|
1133
|
-
// previous Session stays discoverable from the durable header
|
|
1134
|
-
|
|
2192
|
+
// previous Session stays discoverable from the durable header; a
|
|
2193
|
+
// checkpoint return parents at the seed source and marks the
|
|
2194
|
+
// fork seeded with its exact inherited prefix length.
|
|
2195
|
+
meta: {
|
|
2196
|
+
cwd: workspacePath,
|
|
2197
|
+
agentPreset: member.presetId,
|
|
2198
|
+
...(effectiveParent === undefined ? {} : { parentSession: effectiveParent }),
|
|
2199
|
+
...(options?.seed === undefined && !seededFromLedger ? {} : { isSeeded: true }),
|
|
2200
|
+
},
|
|
2201
|
+
...seedOptions,
|
|
1135
2202
|
agentOptions,
|
|
1136
2203
|
setup,
|
|
1137
2204
|
});
|
|
@@ -1142,14 +2209,74 @@ let AgentTeam = (() => {
|
|
|
1142
2209
|
this.modelSelections.set(member.memberId, selected);
|
|
1143
2210
|
this.clearMemberFailure(member.memberId, 'activation');
|
|
1144
2211
|
this.nameMemberSession(member, created.agent);
|
|
1145
|
-
|
|
1146
|
-
|
|
2212
|
+
// The one-shot pressure notice needs no explicit re-arm here: it latches
|
|
2213
|
+
// on durable Session evidence, and a fresh generation's own event span
|
|
2214
|
+
// starts empty — a new Session is itself the re-arm.
|
|
2215
|
+
// Snapshot whether this generation had already started its own turns
|
|
2216
|
+
// BEFORE any recovery delivery below: the handoff steer and the Inbox
|
|
2217
|
+
// wake legitimately append `turn/start` to this Session, and those must
|
|
2218
|
+
// never be mistaken for the generation having run on its own.
|
|
2219
|
+
const generationStarted = created.agent.session.ownEvents().some(event => event.type === 'turn/start');
|
|
2220
|
+
// A restart between a durable rollover intent and its swap replays the
|
|
2221
|
+
// old Session; the projection still carries the intent, so finish the
|
|
2222
|
+
// transition (or keep waiting for the containing turn) from here.
|
|
2223
|
+
if (persisted) {
|
|
2224
|
+
const state = foldContextProjection(created.agent.session.ownEvents(), created.agent.session.inheritedEventCount, created.agent.session.id);
|
|
2225
|
+
if (state.pending !== null)
|
|
2226
|
+
this.contextManagement.recoverPendingTransition(member.memberId, created.agent, member.sessionId);
|
|
2227
|
+
// A restart between one checkpoint's durable result and its quiet
|
|
2228
|
+
// follow-up delivery repairs exactly once; delivered continuations
|
|
2229
|
+
// stay delivered through the projection's own delivery record.
|
|
2230
|
+
this.contextManagement.repairContinuations(created.agent, state);
|
|
2231
|
+
// A restart after the rollover committed but before the handoff was
|
|
2232
|
+
// delivered activates the new Session with no handoff in its own
|
|
2233
|
+
// log — never treat that as an ordinary blank Member Session. The
|
|
2234
|
+
// operation's recorded previous Session (the lineage parent) still
|
|
2235
|
+
// holds the intent; rebuild the handoff from it. "No handoff in its
|
|
2236
|
+
// own log" is judged over both shapes: a generation rescued from the
|
|
2237
|
+
// retired custom kinds carries its handoff as a source the projection
|
|
2238
|
+
// does not classify, and rebuilding on top of it would inject the same
|
|
2239
|
+
// handoff twice.
|
|
2240
|
+
if (!handoffAlreadyInLog(state.boundaries, created.agent.session.ownEvents())) {
|
|
2241
|
+
await this.reconstructMissingHandoff(member, created.agent);
|
|
2242
|
+
}
|
|
2243
|
+
// Carried input redelivery binds to the committed transition target —
|
|
2244
|
+
// this Session — never to the handoff's presence: a crash after the
|
|
2245
|
+
// handoff landed but before the carried enqueue still replays here.
|
|
2246
|
+
recoveryCarried = await this.replayCarriedInput(member, created.agent, generationStarted);
|
|
2247
|
+
}
|
|
2248
|
+
else if (forkedFrom === undefined && lineageParent !== undefined) {
|
|
2249
|
+
// A restart recreated a Session that never materialized before the
|
|
2250
|
+
// crash (the rollover committed, its activation failed, and this
|
|
2251
|
+
// create path just rebuilt it from the ledger binding alone): the
|
|
2252
|
+
// same handoff reconstruction applies from the ledger's recorded
|
|
2253
|
+
// previous Session, and the old generation's unconsumed post-intent
|
|
2254
|
+
// input — the durable fold, the same truth the live transition uses —
|
|
2255
|
+
// rides behind the handoff exactly as it would have. The rollover's
|
|
2256
|
+
// own activation passes its fork parent explicitly and delivers the
|
|
2257
|
+
// handoff right after — that delivery is the plan, never this
|
|
2258
|
+
// recovery.
|
|
2259
|
+
await this.reconstructMissingHandoff(member, created.agent);
|
|
2260
|
+
recoveryCarried = await this.replayCarriedInput(member, created.agent, generationStarted);
|
|
2261
|
+
}
|
|
2262
|
+
// The ordinary Inbox wake runs LAST, after any recovery delivery above:
|
|
2263
|
+
// the handoff must stay the first model-facing context of a recovered
|
|
2264
|
+
// generation, with carried input behind it and rederived Team facts
|
|
2265
|
+
// behind that. When recovery redelivered carried input, the wake is
|
|
2266
|
+
// sequenced behind it (its own follow-up turn) so the steer lane can
|
|
2267
|
+
// never leapfrog the carried messages. A rollover activation defers the
|
|
2268
|
+
// wake entirely — its caller delivers the handoff (and carried input)
|
|
2269
|
+
// first and rederives the Inbox afterwards.
|
|
2270
|
+
if (options?.deferNotify !== true)
|
|
2271
|
+
this.notifyMember(created.agent, recoveryCarried > 0);
|
|
1147
2272
|
}
|
|
1148
2273
|
catch (error) {
|
|
1149
2274
|
await created?.dispose();
|
|
1150
2275
|
this.modelSelections.delete(member.memberId);
|
|
1151
2276
|
this.memberRuntime.forgetMember(member.memberId);
|
|
1152
|
-
|
|
2277
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2278
|
+
this.ctx.logger.warn(`agent-team: activation failed for member '${this.memberLabel(member.memberId)}': ${message}`);
|
|
2279
|
+
this.setMemberFailure(member.memberId, 'activation', message);
|
|
1153
2280
|
}
|
|
1154
2281
|
finally {
|
|
1155
2282
|
// Activation only changes this Workspace's presence projection.
|
|
@@ -1209,6 +2336,12 @@ let AgentTeam = (() => {
|
|
|
1209
2336
|
const scope = scopeOf(agentCtx);
|
|
1210
2337
|
const teamMessage = this.ctx.tools.get('team_message', scope);
|
|
1211
2338
|
if (teamMessage?.[AGENT_TEAM_PRESET_MARKER] !== true) {
|
|
2339
|
+
// `agentPresets.mount` already rejected an unscoped context, so a scope
|
|
2340
|
+
// key the harness sees but this bundle does not means the two sides
|
|
2341
|
+
// loaded different module instances of @deepseek-ai/dsh-scope (the
|
|
2342
|
+
// common trigger is running the CLI from source via tsx).
|
|
2343
|
+
if (scope === undefined)
|
|
2344
|
+
throw new Error(teamPresetScopeMismatchMessage(isTsxDevMode()));
|
|
1212
2345
|
throw new Error('selected preset is not team-enabled');
|
|
1213
2346
|
}
|
|
1214
2347
|
const available = new Set(this.ctx.tools.schemas(scope).map(tool => tool.name));
|
|
@@ -1229,6 +2362,13 @@ let AgentTeam = (() => {
|
|
|
1229
2362
|
const handle = this.handles.get(member.memberId);
|
|
1230
2363
|
if (handle === undefined)
|
|
1231
2364
|
return Object.freeze({ member, availability: 'unavailable', presence: 'unavailable' });
|
|
2365
|
+
// A rollover commits its ledger binding before the old generation retires
|
|
2366
|
+
// and the new one activates; during that window the live handle still runs
|
|
2367
|
+
// the previous Session. The Member stays visible but must not report the
|
|
2368
|
+
// new binding as active — a Client following the row would otherwise open
|
|
2369
|
+
// a Session that does not exist yet.
|
|
2370
|
+
if (handle.agent.id !== member.sessionId)
|
|
2371
|
+
return Object.freeze({ member, availability: 'unavailable', presence: 'unavailable', diagnostic: 'context rollover in progress' });
|
|
1232
2372
|
if (this.ctx.agentPresets.composedPreset(handle.agent.ctx) === undefined) {
|
|
1233
2373
|
return Object.freeze({ member, availability: 'active', presence: 'error', diagnostic: ORPHANED_MEMBER_DIAGNOSTIC });
|
|
1234
2374
|
}
|
|
@@ -1291,16 +2431,41 @@ let AgentTeam = (() => {
|
|
|
1291
2431
|
if (handle !== undefined)
|
|
1292
2432
|
this.notifyMember(handle.agent);
|
|
1293
2433
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
2434
|
+
// Task acceptance no longer schedules standalone auto compaction: it is
|
|
2435
|
+
// a semantic checkpoint/context cue (delivered as ordinary Team
|
|
2436
|
+
// notification), and the Team pressure policy owns compaction entry.
|
|
2437
|
+
}
|
|
2438
|
+
/** Model-visible active-Claim labels for the pressure notice. */
|
|
2439
|
+
activeClaimLabels(memberId) {
|
|
2440
|
+
const ledger = this.ledger;
|
|
2441
|
+
if (ledger === undefined)
|
|
2442
|
+
return [];
|
|
2443
|
+
const labels = [];
|
|
2444
|
+
for (const claim of ledger.activeClaimsForMember(memberId)) {
|
|
2445
|
+
if (claim.state !== 'active')
|
|
2446
|
+
continue;
|
|
2447
|
+
labels.push(`${claim.claimRef} (${claim.direction})`);
|
|
2448
|
+
}
|
|
2449
|
+
return labels;
|
|
2450
|
+
}
|
|
2451
|
+
/** Model-visible running/stopping job labels for the pressure notice. */
|
|
2452
|
+
runningJobLabels(memberId) {
|
|
2453
|
+
const handle = this.handles.get(memberId);
|
|
2454
|
+
if (handle === undefined)
|
|
2455
|
+
return [];
|
|
2456
|
+
const jobs = handle.agent.ctx.get('jobs');
|
|
2457
|
+
if (jobs === undefined)
|
|
2458
|
+
return [];
|
|
2459
|
+
return jobs.list(handle.agent)
|
|
2460
|
+
.filter((job) => job.status === 'running' || job.status === 'stopping')
|
|
2461
|
+
.map((job) => `${job.name ?? job.id}`);
|
|
1297
2462
|
}
|
|
1298
2463
|
emitAutoCompactionChanged(memberId) {
|
|
1299
2464
|
const workspaceId = this.ledger?.getMember(memberId)?.workspaceId;
|
|
1300
2465
|
this.emitChanged(workspaceId === undefined ? undefined : [{ kind: 'workspace', workspaceId }]);
|
|
1301
2466
|
}
|
|
1302
2467
|
/** Wake from durable unread state with bounded facts for direct and state-changing work. */
|
|
1303
|
-
notifyMember(agent) {
|
|
2468
|
+
notifyMember(agent, sequenced = false) {
|
|
1304
2469
|
const member = this.memberForAgent(agent);
|
|
1305
2470
|
if (member === undefined || member.state !== 'enabled')
|
|
1306
2471
|
return;
|
|
@@ -1309,6 +2474,12 @@ let AgentTeam = (() => {
|
|
|
1309
2474
|
this.notifiedInbox.delete(member.memberId);
|
|
1310
2475
|
return;
|
|
1311
2476
|
}
|
|
2477
|
+
// Any ordinary next-turn input already queued (a rollover's or recovery's
|
|
2478
|
+
// carried messages) forces sequencing regardless of the caller: a steer
|
|
2479
|
+
// would claim the nearest step boundary and leapfrog the carried turn.
|
|
2480
|
+
if (!sequenced) {
|
|
2481
|
+
sequenced = agent.inbox.nextTurn.some(message => message.source.kind === 'user');
|
|
2482
|
+
}
|
|
1312
2483
|
const signature = JSON.stringify(notifications.map(({ item }) => [
|
|
1313
2484
|
item.thread.threadRef, item.thread.revision, item.unreadCount, item.directCount, item.newestSequence,
|
|
1314
2485
|
]));
|
|
@@ -1333,7 +2504,15 @@ let AgentTeam = (() => {
|
|
|
1333
2504
|
});
|
|
1334
2505
|
this.notifiedInbox.set(member.memberId, signature);
|
|
1335
2506
|
try {
|
|
1336
|
-
|
|
2507
|
+
// Steer normally claims the nearest step boundary — which would
|
|
2508
|
+
// preempt carried input that a rollover or crash recovery queued as
|
|
2509
|
+
// ordinary next-turn messages. The sequenced mode enqueues the
|
|
2510
|
+
// rederived Inbox as its own follow-up turn instead, so the delivery
|
|
2511
|
+
// order stays handoff → carried input → rederived Inbox.
|
|
2512
|
+
if (sequenced)
|
|
2513
|
+
agent.followup(hint);
|
|
2514
|
+
else
|
|
2515
|
+
agent.steer(hint);
|
|
1337
2516
|
}
|
|
1338
2517
|
catch (error) {
|
|
1339
2518
|
this.clearMemberNotificationState(member.memberId);
|
|
@@ -1374,7 +2553,7 @@ let AgentTeam = (() => {
|
|
|
1374
2553
|
if (direct && fact.kind === 'message') {
|
|
1375
2554
|
const sender = fact.message.sender === AGENT_TEAM_HUMAN_MEMBER_ID
|
|
1376
2555
|
? 'human' : this.requireLedger().getMember(fact.message.sender)?.handle ?? fact.message.sender;
|
|
1377
|
-
const detail = ['Direct Team mention', `From: ${sender}`, `Channel: ${item.channelRef}`,
|
|
2556
|
+
const detail = ['Direct Team mention', `Occurred at: ${formatTeamTimestamp(fact.occurredAt)}`, `From: ${sender}`, `Channel: ${item.channelRef}`,
|
|
1378
2557
|
...(item.task === undefined ? [] : [`Task: ${item.task.taskRef}`]),
|
|
1379
2558
|
`Thread: ${item.thread.threadRef}`, `Message ref: ${fact.message.messageRef}`,
|
|
1380
2559
|
`Message: ${this.boundedNotificationBody(fact.message.body)}`].join('\n');
|
|
@@ -1382,15 +2561,15 @@ let AgentTeam = (() => {
|
|
|
1382
2561
|
detailedFactCount += 1;
|
|
1383
2562
|
}
|
|
1384
2563
|
else if (fact.kind === 'activity') {
|
|
1385
|
-
const detail = `${this.activityNotification(fact.activity, readerId)}\nThread: ${item.thread.threadRef}`;
|
|
2564
|
+
const detail = `${this.activityNotification(fact.activity, readerId)}\nOccurred at: ${formatTeamTimestamp(fact.occurredAt)}\nThread: ${item.thread.threadRef}`;
|
|
1386
2565
|
if (append(detail))
|
|
1387
2566
|
detailedFactCount += 1;
|
|
1388
2567
|
}
|
|
1389
2568
|
}
|
|
1390
|
-
const
|
|
1391
|
-
if (
|
|
2569
|
+
const ordinary = facts.filter(entry => entry.fact.kind === 'message' && !entry.direct);
|
|
2570
|
+
if (ordinary.length > 0) {
|
|
1392
2571
|
const route = item.task === undefined ? `Thread ${item.thread.threadRef}` : `Task ${item.task.taskRef}`;
|
|
1393
|
-
append(`${route}: ${
|
|
2572
|
+
append(`${route}: ${ordinary.length} unread update${ordinary.length === 1 ? '' : 's'} · newest at ${formatTeamTimestamp(ordinary.at(-1).fact.occurredAt)}.`);
|
|
1394
2573
|
}
|
|
1395
2574
|
}
|
|
1396
2575
|
if (omitted)
|
|
@@ -1405,11 +2584,28 @@ let AgentTeam = (() => {
|
|
|
1405
2584
|
activityNotification(activity, readerId) {
|
|
1406
2585
|
const actor = activity.actor === AGENT_TEAM_HUMAN_MEMBER_ID
|
|
1407
2586
|
? 'human' : this.requireLedger().getMember(activity.actor)?.handle ?? activity.actor;
|
|
1408
|
-
//
|
|
1409
|
-
//
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
2587
|
+
// An acceptance concludes the reader's own Claims in two ways: a Claim
|
|
2588
|
+
// already finished before the accept was accepted (its work stands), and
|
|
2589
|
+
// a still-open Claim the accept completed atomically (no further work is
|
|
2590
|
+
// needed). Both must be said plainly, or the owner keeps working on a
|
|
2591
|
+
// done Task or never learns its contribution was accepted.
|
|
2592
|
+
if (activity.kind === 'accept' && activity.actor === AGENT_TEAM_HUMAN_MEMBER_ID && readerId !== undefined) {
|
|
2593
|
+
const acceptedOwn = (activity.acceptedClaimRefs ?? []).filter(claimRef => this.requireLedger().getClaim(claimRef)?.owner === readerId);
|
|
2594
|
+
const completedOwn = (activity.completedClaimRefs ?? []).filter(claimRef => this.requireLedger().getClaim(claimRef)?.owner === readerId);
|
|
2595
|
+
// The early-accept-only reader holds no finished Claim: every own Claim
|
|
2596
|
+
// was completed atomically, so the "finished Claim" clause's ref list
|
|
2597
|
+
// would be empty. Render the completed semantics alone instead of a
|
|
2598
|
+
// sentence with a dangling empty list.
|
|
2599
|
+
const finishedOwn = acceptedOwn.filter(claimRef => !completedOwn.includes(claimRef));
|
|
2600
|
+
if (completedOwn.length > 0 && finishedOwn.length > 0) {
|
|
2601
|
+
return `Team Task update\n${actor} accepted Task ${activity.taskRef}. Your Claim ${completedOwn.join(', ')} was completed with the acceptance, and your finished Claim ${finishedOwn.join(', ')} was accepted. No further work is needed.`;
|
|
2602
|
+
}
|
|
2603
|
+
if (completedOwn.length > 0) {
|
|
2604
|
+
return `Team Task update\n${actor} accepted Task ${activity.taskRef} and your open Claim ${completedOwn.join(', ')} was completed with it. No further work is needed.`;
|
|
2605
|
+
}
|
|
2606
|
+
if (acceptedOwn.length > 0) {
|
|
2607
|
+
return `Team Task update\n${actor} accepted Task ${activity.taskRef}; your finished Claim ${acceptedOwn.join(', ')} was accepted. No further work is needed.`;
|
|
2608
|
+
}
|
|
1413
2609
|
}
|
|
1414
2610
|
if (activity.kind === 'claim' || activity.kind === 'done' || activity.kind === 'release') {
|
|
1415
2611
|
return `Team Task update\n${actor} ${activity.kind} Claim ${activity.claimRef} on Task ${activity.taskRef}.`;
|