@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
3
3
|
import { SessionLogOffset } from '@deepseek-ai/dsh-session';
|
|
4
|
+
import { memberMemoryDirectoryPath } from "./member-runtime.js";
|
|
4
5
|
export const name = 'wowyuarm-agent-team-member-context';
|
|
5
6
|
const MAX_MEMORY_BYTES = 8 * 1024;
|
|
6
7
|
const BEGIN = '<team-member-private-memory>';
|
|
@@ -20,12 +21,15 @@ export function apply(ctx) {
|
|
|
20
21
|
const member = host.memberForAgent(agent);
|
|
21
22
|
if (member === undefined)
|
|
22
23
|
return decision;
|
|
24
|
+
// The sanitized path is authoritative: activation migrated any legacy
|
|
25
|
+
// colon directory onto it before this member could run a step.
|
|
26
|
+
const memoryPath = memberMemoryDirectoryPath(member);
|
|
23
27
|
let memory;
|
|
24
28
|
try {
|
|
25
|
-
memory = renderMemberMemory(await readFile(`${
|
|
29
|
+
memory = renderMemberMemory(await readFile(`${memoryPath}/memory.md`), memoryPath);
|
|
26
30
|
}
|
|
27
31
|
catch (error) {
|
|
28
|
-
memory = renderUnavailableMemory(
|
|
32
|
+
memory = renderUnavailableMemory(memoryPath, error.code === 'ENOENT'
|
|
29
33
|
? 'memory.md is absent; the private memory index is empty.'
|
|
30
34
|
: 'memory.md is currently unreadable; do not use any earlier private memory context.');
|
|
31
35
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* @module @wowyuarm/dsh-agent-team/member-runtime
|
|
15
15
|
*/
|
|
16
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
17
17
|
import { dirname, join, resolve } from 'node:path';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { scopeOf } from '@deepseek-ai/dsh-scope';
|
|
@@ -32,6 +32,9 @@ export const AGENT_TEAM_TOOL_NAMES = Object.freeze([
|
|
|
32
32
|
'team_message',
|
|
33
33
|
'team_claim',
|
|
34
34
|
'team_view',
|
|
35
|
+
'context_rollover',
|
|
36
|
+
'context_checkpoint',
|
|
37
|
+
'context_timeline',
|
|
35
38
|
]);
|
|
36
39
|
/** Copy a Remote-supplied capability overlay into owned frozen storage. */
|
|
37
40
|
export function deepCopyCapabilities(capabilities) {
|
|
@@ -41,6 +44,31 @@ export function deepCopyCapabilities(capabilities) {
|
|
|
41
44
|
...(capabilities.skills === undefined ? {} : { skills: copyAllow(capabilities.skills.allow) }),
|
|
42
45
|
};
|
|
43
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Filesystem directory segment for one Member's private memory namespace.
|
|
49
|
+
* The `member:<uuid>` ref is a durable ledger identity and must never appear
|
|
50
|
+
* in a path: Windows rejects `:` in a path segment (NTFS parses it as an
|
|
51
|
+
* Alternate Data Stream separator), which made Member activation fail at its
|
|
52
|
+
* first `mkdir` on Windows (issue #7).
|
|
53
|
+
*/
|
|
54
|
+
export function memberMemoryDirectoryName(memberId) {
|
|
55
|
+
return memberId.replaceAll(':', '-');
|
|
56
|
+
}
|
|
57
|
+
/** The sanitized absolute private-memory path for one Member, regardless of what the ledger recorded. */
|
|
58
|
+
export function memberMemoryDirectoryPath(member) {
|
|
59
|
+
// Sanitize per segment: only a segment that actually contains the ledger's
|
|
60
|
+
// member identity colon needs rewriting, and the recorded final segment is
|
|
61
|
+
// the only place it can appear. The drive-letter colon of a Windows
|
|
62
|
+
// absolute prefix is not a member identity and must survive untouched, so
|
|
63
|
+
// a colon-free final segment keeps the recorded path verbatim even when
|
|
64
|
+
// earlier segments carry colons.
|
|
65
|
+
const separator = Math.max(member.privateMemoryPath.lastIndexOf('/'), member.privateMemoryPath.lastIndexOf('\\'));
|
|
66
|
+
const finalSegment = separator === -1 ? member.privateMemoryPath : member.privateMemoryPath.slice(separator + 1);
|
|
67
|
+
if (!finalSegment.includes(':'))
|
|
68
|
+
return member.privateMemoryPath;
|
|
69
|
+
const prefix = separator === -1 ? '' : member.privateMemoryPath.slice(0, separator + 1);
|
|
70
|
+
return prefix + memberMemoryDirectoryName(member.memberId);
|
|
71
|
+
}
|
|
44
72
|
/** Per-Member runtime state; the four maps dispose together with each Member. */
|
|
45
73
|
export class MemberRuntime {
|
|
46
74
|
deps;
|
|
@@ -165,10 +193,12 @@ export class MemberRuntime {
|
|
|
165
193
|
* scope layer (the traceable-service seam, like the tool restriction):
|
|
166
194
|
* bundled read-only core skills plus this Member's own private directory,
|
|
167
195
|
* with the live selection ref that later capability edits swap in place.
|
|
196
|
+
* The sanitized directory is authoritative: activateMember migrated any
|
|
197
|
+
* legacy colon directory onto it before the provider mounted.
|
|
168
198
|
*/
|
|
169
199
|
mountMemberSkillProvider(member, agentCtx, selection) {
|
|
170
200
|
this.skillProviderDisposals.set(member.memberId, memberSkills.mountMemberSkillProvider(agentCtx, {
|
|
171
|
-
skillsDirectory: join(member
|
|
201
|
+
skillsDirectory: join(memberMemoryDirectoryPath(member), 'skills'),
|
|
172
202
|
bundledSkillsDirectory: BUNDLED_SKILLS_DIRECTORY,
|
|
173
203
|
selection,
|
|
174
204
|
}));
|
|
@@ -183,8 +213,29 @@ export class MemberRuntime {
|
|
|
183
213
|
* first-run memory.md scaffold. The Member-private skills directory starts
|
|
184
214
|
* empty; the per-Member provider scans exactly this root (default roots
|
|
185
215
|
* excluded).
|
|
216
|
+
*
|
|
217
|
+
* Existing installs recorded the pre-fix colon directory in the ledger, and
|
|
218
|
+
* `privateMemoryPath` is a durable Member fact the renewal path cannot
|
|
219
|
+
* rewrite: when the legacy directory exists it is renamed onto the sanitized
|
|
220
|
+
* path once (same-parent rename, atomic), so existing private memory
|
|
221
|
+
* survives instead of being silently orphaned.
|
|
222
|
+
*
|
|
223
|
+
* A colon-form twin directory is also merged when it exists: a Member may
|
|
224
|
+
* have written files under the ledger identity spelling (the branded
|
|
225
|
+
* `member:<uuid>` ref is what every Team tool result shows, so a
|
|
226
|
+
* hand-assembled path carries the colon). Linux accepts the segment
|
|
227
|
+
* silently — two directories for one Member — while Windows would have
|
|
228
|
+
* failed the write outright. The twin is never a durable-Member-Fact
|
|
229
|
+
* candidate: only the files the Member actually wrote there are worth
|
|
230
|
+
* keeping, and `memory.md` cannot merge, so the sanitized copy always wins
|
|
231
|
+
* and twin-only notes/skills are moved in without overwriting.
|
|
186
232
|
*/
|
|
187
|
-
async initializePrivateMemory(path) {
|
|
233
|
+
async initializePrivateMemory(path, legacyPath) {
|
|
234
|
+
if (legacyPath !== undefined && legacyPath !== path)
|
|
235
|
+
await migrateLegacyMemoryDirectory(legacyPath, path);
|
|
236
|
+
const twinPath = twinMemoryDirectoryPath(path);
|
|
237
|
+
if (twinPath !== undefined)
|
|
238
|
+
await mergeTwinMemoryDirectory(twinPath, path);
|
|
188
239
|
await mkdir(join(path, 'notes'), { recursive: true });
|
|
189
240
|
await mkdir(join(path, 'skills'), { recursive: true });
|
|
190
241
|
try {
|
|
@@ -197,9 +248,14 @@ export class MemberRuntime {
|
|
|
197
248
|
}
|
|
198
249
|
/** Irreversibly remove one Member: archive its Session and delete its private namespace. */
|
|
199
250
|
async cleanupRemovedMember(member) {
|
|
251
|
+
// The ledger path may still name the legacy colon directory (never
|
|
252
|
+
// activated after the fix): remove both spellings; rm is force-tolerant
|
|
253
|
+
// of the one that does not exist.
|
|
254
|
+
const sanitized = memberMemoryDirectoryPath(member);
|
|
200
255
|
const results = await Promise.allSettled([
|
|
201
256
|
this.deps.ctx.workspaceRegistry.archiveSession(member.sessionId),
|
|
202
|
-
rm(
|
|
257
|
+
rm(sanitized, { recursive: true, force: true }),
|
|
258
|
+
...(sanitized === member.privateMemoryPath ? [] : [rm(member.privateMemoryPath, { recursive: true, force: true })]),
|
|
203
259
|
]);
|
|
204
260
|
const failures = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
|
|
205
261
|
if (failures.length > 0)
|
|
@@ -227,3 +283,102 @@ export class MemberRuntime {
|
|
|
227
283
|
this.capabilityWarnings.clear();
|
|
228
284
|
}
|
|
229
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* One-time in-place migration of a pre-fix colon-named private memory
|
|
288
|
+
* directory onto its sanitized path. A sanitized target that already exists
|
|
289
|
+
* wins (idempotent across restarts and partially migrated installs); a legacy
|
|
290
|
+
* source that never existed is simply the fresh-install case.
|
|
291
|
+
*/
|
|
292
|
+
async function migrateLegacyMemoryDirectory(legacyPath, path) {
|
|
293
|
+
let legacy;
|
|
294
|
+
try {
|
|
295
|
+
legacy = await stat(legacyPath);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (error.code === 'ENOENT')
|
|
299
|
+
return;
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
if (!legacy.isDirectory())
|
|
303
|
+
throw new Error(`legacy Member memory path '${legacyPath}' exists but is not a directory`);
|
|
304
|
+
try {
|
|
305
|
+
await stat(path);
|
|
306
|
+
return; // Sanitized directory already present: migration already done or a new install.
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
if (error.code !== 'ENOENT')
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
await rename(legacyPath, path);
|
|
313
|
+
}
|
|
314
|
+
/** The colon-form twin path of one sanitized Member memory directory, when the segment form admits one. */
|
|
315
|
+
function twinMemoryDirectoryPath(sanitizedPath) {
|
|
316
|
+
const separator = Math.max(sanitizedPath.lastIndexOf('/'), sanitizedPath.lastIndexOf('\\'));
|
|
317
|
+
const prefix = separator === -1 ? '' : sanitizedPath.slice(0, separator + 1);
|
|
318
|
+
const finalSegment = separator === -1 ? sanitizedPath : sanitizedPath.slice(separator + 1);
|
|
319
|
+
// Only the exact sanitized identity segment (`member-<uuid>`) has a colon
|
|
320
|
+
// twin (`member:<uuid>`); any other segment shape has no identity mapping.
|
|
321
|
+
if (!/^member-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(finalSegment))
|
|
322
|
+
return undefined;
|
|
323
|
+
return `${prefix}member:${finalSegment.slice('member-'.length)}`;
|
|
324
|
+
}
|
|
325
|
+
/** The colon-twin trace name for one conflicting file: `note.md` → `note.colon-twin.md`. */
|
|
326
|
+
function colonTwinTraceName(name) {
|
|
327
|
+
const dot = name.lastIndexOf('.');
|
|
328
|
+
return dot <= 0 ? `${name}.colon-twin` : `${name.slice(0, dot)}.colon-twin${name.slice(dot)}`;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Merge a hand-created colon-form twin directory into the sanitized Member
|
|
332
|
+
* memory directory. Unlike the ledger legacy migration this is a merge, not a
|
|
333
|
+
* rename: the sanitized directory is the Member's live root (the injected
|
|
334
|
+
* paths and the skill provider point at it), so on a same-path conflict the
|
|
335
|
+
* live root's copy wins and the twin's losing copy is preserved beside it
|
|
336
|
+
* under a `.colon-twin` name instead of being silently discarded. Twin-only
|
|
337
|
+
* files move in under their own names; the emptied twin directory is then
|
|
338
|
+
* removed so the drift cannot silently recur.
|
|
339
|
+
*/
|
|
340
|
+
async function mergeTwinMemoryDirectory(twinPath, path) {
|
|
341
|
+
let twin;
|
|
342
|
+
try {
|
|
343
|
+
twin = await stat(twinPath);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
if (error.code !== 'ENOENT')
|
|
347
|
+
throw error;
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (!twin.isDirectory())
|
|
351
|
+
return;
|
|
352
|
+
await mergeDirectoryContents(twinPath, path);
|
|
353
|
+
await rm(twinPath, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
/** Recursively move every twin file into the live root; an existing live file wins, its twin copy traced beside it. */
|
|
356
|
+
async function mergeDirectoryContents(source, target) {
|
|
357
|
+
await mkdir(target, { recursive: true });
|
|
358
|
+
for (const entry of await readdir(source, { withFileTypes: true })) {
|
|
359
|
+
const sourcePath = join(source, entry.name);
|
|
360
|
+
const targetPath = join(target, entry.name);
|
|
361
|
+
if (entry.isDirectory()) {
|
|
362
|
+
await mergeDirectoryContents(sourcePath, targetPath);
|
|
363
|
+
}
|
|
364
|
+
else if (entry.isFile()) {
|
|
365
|
+
// rename(2) silently REPLACES an existing target on POSIX (the EEXIST
|
|
366
|
+
// error only exists on Windows, where the colon twin cannot exist at
|
|
367
|
+
// all), so a collision must be detected, not caught: probe the target
|
|
368
|
+
// first. Activation is the single serialized writer, so the
|
|
369
|
+
// probe-to-rename window has no concurrent writer to race.
|
|
370
|
+
let liveFilePresent = true;
|
|
371
|
+
try {
|
|
372
|
+
await stat(targetPath);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
if (error.code !== 'ENOENT')
|
|
376
|
+
throw error;
|
|
377
|
+
liveFilePresent = false;
|
|
378
|
+
}
|
|
379
|
+
// The live root stays authoritative; the twin's losing copy stays
|
|
380
|
+
// traceable beside it instead of being silently discarded.
|
|
381
|
+
await rename(sourcePath, liveFilePresent ? join(target, colonTwinTraceName(entry.name)) : targetPath);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team Member turn-level clock context.
|
|
3
|
+
*
|
|
4
|
+
* The first model step of every eligible Team Member turn appends one
|
|
5
|
+
* durable, source-attributed clock snapshot: the current instant in the
|
|
6
|
+
* fixed Team coordination zone (UTC+8), the elapsed time since the
|
|
7
|
+
* preceding model-visible event, and the ordering authority note. Later
|
|
8
|
+
* steps of the same turn stay quiet unless the turn runs longer than the
|
|
9
|
+
* refresh interval, in which case one snapshot lands per elapsed interval —
|
|
10
|
+
* a tool-dense turn of quick steps produces exactly one line, while a turn
|
|
11
|
+
* that outlives the interval still shows its real span. The snapshot is an
|
|
12
|
+
* observation, never ledger authority: sequence and revision, not
|
|
13
|
+
* wall-clock time, order Team facts.
|
|
14
|
+
*
|
|
15
|
+
* This plugin deliberately does not mount the shipped
|
|
16
|
+
* `@deepseek-ai/dsh-time-context`: its browser-zone policy asks the model to
|
|
17
|
+
* confirm dates with the user whenever a request carries no unique browser
|
|
18
|
+
* zone, which is the normal case for background Member wakes (Inbox, DM,
|
|
19
|
+
* recovery, continuation). The Team coordination zone is fixed instead. If
|
|
20
|
+
* the harness grows a public non-browser/canonical-zone policy, retire this
|
|
21
|
+
* row in favor of configuring that plugin.
|
|
22
|
+
*
|
|
23
|
+
* State is folded from the Member Session's own events — the same
|
|
24
|
+
* manual-fold pattern the Host's context projection uses — so restart,
|
|
25
|
+
* request reconstruction, and compaction all derive identical baselines
|
|
26
|
+
* without a second durable store.
|
|
27
|
+
* @module @wowyuarm/dsh-agent-team/member-time-context
|
|
28
|
+
*/
|
|
29
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
30
|
+
import { formatTeamDuration, formatTeamTimestamp } from "./time-format.js";
|
|
31
|
+
export const name = 'wowyuarm-agent-team-member-time-context';
|
|
32
|
+
/** Default minimum spacing between two snapshots within one turn, in ms. */
|
|
33
|
+
export const CLOCK_REFRESH_INTERVAL_MS = 1_800_000;
|
|
34
|
+
function emptyBaseline() {
|
|
35
|
+
return { lastMessageTime: null, lastInjectionTime: null, lastTurnInjectionTime: null, openTurn: -1 };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Fold one session event into the clock baseline. Uninterested events return
|
|
39
|
+
* the same state reference.
|
|
40
|
+
* @internal exported for tests.
|
|
41
|
+
*/
|
|
42
|
+
export function applyClockEvent(state, event) {
|
|
43
|
+
switch (event.type) {
|
|
44
|
+
case 'turn/start':
|
|
45
|
+
return event.data.turn === state.openTurn ? state
|
|
46
|
+
: { ...state, lastTurnInjectionTime: null, openTurn: event.data.turn };
|
|
47
|
+
case 'turn/end':
|
|
48
|
+
return state.openTurn === -1 ? state : { ...state, lastTurnInjectionTime: null, openTurn: -1 };
|
|
49
|
+
case 'user/message': {
|
|
50
|
+
const source = event.data.source;
|
|
51
|
+
const injected = source.kind === 'plugin' && source.plugin === name;
|
|
52
|
+
const withMessage = state.lastMessageTime === event.time ? state : { ...state, lastMessageTime: event.time };
|
|
53
|
+
if (!injected)
|
|
54
|
+
return withMessage;
|
|
55
|
+
return { ...withMessage, lastInjectionTime: event.time, lastTurnInjectionTime: event.time };
|
|
56
|
+
}
|
|
57
|
+
case 'assistant/message':
|
|
58
|
+
case 'tool/result':
|
|
59
|
+
return state.lastMessageTime === event.time ? state : { ...state, lastMessageTime: event.time };
|
|
60
|
+
default:
|
|
61
|
+
return state;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Fold a whole event log into the clock baseline. @internal exported for tests. */
|
|
65
|
+
export function foldClockBaseline(events) {
|
|
66
|
+
let state = emptyBaseline();
|
|
67
|
+
for (const event of events)
|
|
68
|
+
state = applyClockEvent(state, event);
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Whether this step should append a clock snapshot: the first step of a turn
|
|
73
|
+
* always does (every wake starts with a fresh instant), and a later step
|
|
74
|
+
* does only when the turn has run longer than the refresh interval since
|
|
75
|
+
* the last landed snapshot. Skipped steps produce nothing and never
|
|
76
|
+
* backfill — their span folds into the next snapshot's elapsed.
|
|
77
|
+
* @internal exported for tests.
|
|
78
|
+
*/
|
|
79
|
+
export function shouldSampleClock(step, now, baseline, refreshIntervalMs) {
|
|
80
|
+
if (step === 1)
|
|
81
|
+
return true;
|
|
82
|
+
return baseline.lastTurnInjectionTime === null || now - baseline.lastTurnInjectionTime >= refreshIntervalMs;
|
|
83
|
+
}
|
|
84
|
+
/** Render one durable clock snapshot text. @internal exported for tests. */
|
|
85
|
+
export function renderClockSnapshot(input) {
|
|
86
|
+
// A wall-clock rollback clamps elapsed to 0s without rewriting history.
|
|
87
|
+
const elapsed = input.previous === undefined ? 'unavailable' : formatTeamDuration(input.now - input.previous);
|
|
88
|
+
const baseline = input.step === 1 ? 'model-visible event' : 'step context';
|
|
89
|
+
return `Team clock sampled while preparing turn ${input.turn}, step ${input.step}: ${formatTeamTimestamp(new Date(input.now).toISOString())}\n`
|
|
90
|
+
+ `Elapsed since the preceding ${baseline}: ${elapsed}.\n`
|
|
91
|
+
+ 'Team collaboration timestamps use UTC+8. Sequence and revision, not wall-clock time, determine ordering and concurrency.';
|
|
92
|
+
}
|
|
93
|
+
export function apply(ctx, config = {}) {
|
|
94
|
+
const refreshIntervalMs = config.refreshIntervalMs ?? CLOCK_REFRESH_INTERVAL_MS;
|
|
95
|
+
ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
|
|
96
|
+
const decision = await next();
|
|
97
|
+
if (decision.kind === 'reject' || signal.aborted)
|
|
98
|
+
return decision;
|
|
99
|
+
// The Host service is resolved at step time, never through plugin inject
|
|
100
|
+
// (same reason as member-context: the row mounts while the Host itself
|
|
101
|
+
// is still restoring Members).
|
|
102
|
+
const host = ctx.get('agentTeam');
|
|
103
|
+
if (host === undefined)
|
|
104
|
+
return decision;
|
|
105
|
+
if (host.memberForAgent(agent) === undefined)
|
|
106
|
+
return decision;
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
// A rollover starts a fresh Session log, so the fold never guesses
|
|
109
|
+
// elapsed across generations: a missing prior event renders
|
|
110
|
+
// `unavailable`, not a fabricated baseline.
|
|
111
|
+
const baseline = foldClockBaseline(agent.session.ownEvents());
|
|
112
|
+
// Turn-first-step always samples; later steps sample only at the refresh
|
|
113
|
+
// interval, so a quick tool-dense turn stays at one line.
|
|
114
|
+
if (!shouldSampleClock(step, now, baseline, refreshIntervalMs))
|
|
115
|
+
return decision;
|
|
116
|
+
const previous = step === 1
|
|
117
|
+
? baseline.lastMessageTime ?? undefined
|
|
118
|
+
: baseline.lastTurnInjectionTime ?? undefined;
|
|
119
|
+
const text = renderClockSnapshot({ now, turn, step, previous });
|
|
120
|
+
const message = createUserMessage({
|
|
121
|
+
content: [{ type: 'text', text }],
|
|
122
|
+
source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
|
|
123
|
+
});
|
|
124
|
+
return { kind: 'enter', messages: [...decision.messages, message] };
|
|
125
|
+
}, { prepend: true });
|
|
126
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team-owned context pressure policy.
|
|
3
|
+
*
|
|
4
|
+
* The Team preset mounts `compaction-basic` with `auto: false`; this
|
|
5
|
+
* coordinator owns both policy entry points instead — the proactive handoff
|
|
6
|
+
* notice near the effective handoff budget and the forced CompactionEngine
|
|
7
|
+
* reduction before any request at or above the effective hard limit — while
|
|
8
|
+
* reusing the public CompactionEngine implementation. The hard-limit
|
|
9
|
+
* translation lives in exactly one method; no caller re-derives it.
|
|
10
|
+
*
|
|
11
|
+
* Fail-closed rule: at the hard limit, a compaction that no-ops, throws, is
|
|
12
|
+
* cancelled, or does not advance the durable surface must block the model
|
|
13
|
+
* request (reject the step) rather than knowingly submit over the Team limit.
|
|
14
|
+
* A blocked Member keeps its log and reports a recoverable error.
|
|
15
|
+
* @module @wowyuarm/dsh-agent-team/pressure-policy
|
|
16
|
+
*/
|
|
17
|
+
import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
|
|
18
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
19
|
+
import { CONTEXT_PRESSURE_NOTICE_SUMMARY } from "./context-management.js";
|
|
20
|
+
import { AGENT_TEAM_PLUGIN_ID } from "./context-source.js";
|
|
21
|
+
/** One pressure-notice text; concise, structured, and inside the reserve. */
|
|
22
|
+
export function contextPressureNoticeText(input) {
|
|
23
|
+
const claims = input.activeClaims.length === 0 ? 'none' : input.activeClaims.join(', ');
|
|
24
|
+
const jobs = input.runningJobs.length === 0 ? 'none' : `${input.runningJobs.length} running (collect or stop them before switching)`;
|
|
25
|
+
return [
|
|
26
|
+
`Context pressure: ${input.usageTokens} tokens measured; the handoff budget is ${input.handoffAt} and the hard limit is ${input.hardLimit}.`,
|
|
27
|
+
`Active Claims: ${claims}. Owner jobs: ${jobs}.`,
|
|
28
|
+
'Finish the current atomic action, then call context_rollover with a handoff covering your objective, verified facts, and external side effects — a fresh context is the default path. Record anything durable in your private memory/notes first.',
|
|
29
|
+
].join(' ');
|
|
30
|
+
}
|
|
31
|
+
/** Whether one user message is this policy's one-shot pressure notice. */
|
|
32
|
+
function isPressureNotice(message) {
|
|
33
|
+
const source = message.source;
|
|
34
|
+
return source?.plugin === AGENT_TEAM_PLUGIN_ID
|
|
35
|
+
&& source?.summary === CONTEXT_PRESSURE_NOTICE_SUMMARY;
|
|
36
|
+
}
|
|
37
|
+
export class PressurePolicyCoordinator {
|
|
38
|
+
options;
|
|
39
|
+
/**
|
|
40
|
+
* Retry budget per agent for the current provider-overflow sequence.
|
|
41
|
+
* Process-only by design: a restart re-earns one sequence per chain.
|
|
42
|
+
*/
|
|
43
|
+
overflowRetries = new Map();
|
|
44
|
+
disposed = false;
|
|
45
|
+
constructor(options) {
|
|
46
|
+
this.options = options;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The one-shot pressure notice is durable Session evidence, not process
|
|
50
|
+
* state: a `CONTEXT_PRESSURE_NOTICE_SUMMARY` notice already surfaced as a
|
|
51
|
+
* `user/message`, or still queued in a durable `agent/inbox/spliced`
|
|
52
|
+
* insert (a steered notice surfaces only at the next step boundary, and a
|
|
53
|
+
* Host restart replays the splice before surfacing), marks the current
|
|
54
|
+
* generation as already notified. A resume or restart stays quiet; a
|
|
55
|
+
* rollover starts a fresh Session whose own event span has no notice yet,
|
|
56
|
+
* which is exactly the documented re-arm.
|
|
57
|
+
*/
|
|
58
|
+
noticeDelivered(agent) {
|
|
59
|
+
for (const event of agent.session.ownEvents()) {
|
|
60
|
+
if (event.type === 'user/message' && isPressureNotice(event.data))
|
|
61
|
+
return true;
|
|
62
|
+
if (event.type === 'agent/inbox/spliced' && event.data.inserted.some(isPressureNotice))
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
dispose() {
|
|
68
|
+
this.disposed = true;
|
|
69
|
+
this.overflowRetries.clear();
|
|
70
|
+
}
|
|
71
|
+
/** A successful assistant response ends any open overflow-recovery sequence. */
|
|
72
|
+
onAssistantMessage(agent) {
|
|
73
|
+
this.overflowRetries.delete(agent);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Pre-step policy for one Member agent: below the handoff budget nothing
|
|
77
|
+
* happens; at the handoff budget one structured notice per generation is
|
|
78
|
+
* steered into the running turn; at the hard limit the request is forced
|
|
79
|
+
* through compaction first and fails closed when that cannot be proven.
|
|
80
|
+
* Returns the decision for the step: `continue` forwards, `reject` blocks.
|
|
81
|
+
*/
|
|
82
|
+
async onPreStep(agent, signal) {
|
|
83
|
+
if (this.disposed || signal.aborted)
|
|
84
|
+
return { kind: 'continue' };
|
|
85
|
+
const member = this.options.memberForAgent(agent);
|
|
86
|
+
if (member === undefined)
|
|
87
|
+
return { kind: 'continue' };
|
|
88
|
+
const limits = await this.options.limitsForAgent(agent);
|
|
89
|
+
if (limits === undefined) {
|
|
90
|
+
// A missing route capacity must be explicit, never an accidental
|
|
91
|
+
// unlimited policy: reject the step with a recoverable diagnostic.
|
|
92
|
+
const diagnostic = 'context pressure policy: the routed model capacity is unknown; refusing to forward a request without a bounded context budget';
|
|
93
|
+
this.options.failed(member.memberId, member.sessionId, diagnostic);
|
|
94
|
+
return { kind: 'reject' };
|
|
95
|
+
}
|
|
96
|
+
const { usageTokens, hardLimit, handoffAt } = limits;
|
|
97
|
+
if (usageTokens >= hardLimit) {
|
|
98
|
+
const outcome = await this.enforceHardLimit(agent, member.memberId, member.sessionId, signal);
|
|
99
|
+
return outcome ? { kind: 'continue' } : { kind: 'reject' };
|
|
100
|
+
}
|
|
101
|
+
if (usageTokens >= handoffAt && !this.noticeDelivered(agent)) {
|
|
102
|
+
const notice = createUserMessage({
|
|
103
|
+
content: [{ type: 'text', text: contextPressureNoticeText({
|
|
104
|
+
usageTokens, handoffAt, hardLimit,
|
|
105
|
+
activeClaims: this.options.activeClaimLabels(member.memberId),
|
|
106
|
+
runningJobs: this.options.runningJobLabels(member.memberId),
|
|
107
|
+
}) }],
|
|
108
|
+
source: { kind: 'plugin', plugin: AGENT_TEAM_PLUGIN_ID, form: 'notice', summary: CONTEXT_PRESSURE_NOTICE_SUMMARY },
|
|
109
|
+
});
|
|
110
|
+
try {
|
|
111
|
+
agent.steer(notice);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
this.options.log(`context pressure notice failed: ${error instanceof Error ? error.message : String(error)} (member ${member.memberId})`);
|
|
115
|
+
}
|
|
116
|
+
return { kind: 'notice' };
|
|
117
|
+
}
|
|
118
|
+
return { kind: 'continue' };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Provider-overflow recovery: one bounded compact-and-retry sequence per
|
|
122
|
+
* open failure chain. Returns whether the request may retry once.
|
|
123
|
+
*/
|
|
124
|
+
async onRequestError(agent, failure, signal) {
|
|
125
|
+
if (this.disposed || signal.aborted)
|
|
126
|
+
return false;
|
|
127
|
+
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE)
|
|
128
|
+
return false;
|
|
129
|
+
const member = this.options.memberForAgent(agent);
|
|
130
|
+
if (member === undefined)
|
|
131
|
+
return false;
|
|
132
|
+
const retries = this.overflowRetries.get(agent) ?? 0;
|
|
133
|
+
if (retries >= 1)
|
|
134
|
+
return false;
|
|
135
|
+
const engine = this.options.compactionForAgent(agent);
|
|
136
|
+
if (engine === undefined)
|
|
137
|
+
return false;
|
|
138
|
+
const generation = agent.session.surface.replaceGeneration;
|
|
139
|
+
try {
|
|
140
|
+
await engine.compactIfNeeded(agent, 'context-overflow', signal);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
// Durable prune progress before a later summary failure justifies the
|
|
144
|
+
// single retry; cancellation never does.
|
|
145
|
+
if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
|
|
146
|
+
this.overflowRetries.set(agent, retries + 1);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
this.options.log(`context-overflow recovery failed: ${error instanceof Error ? error.message : String(error)} (member ${member.memberId})`);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
if (signal.aborted || agent.session.surface.replaceGeneration <= generation)
|
|
153
|
+
return false;
|
|
154
|
+
this.overflowRetries.set(agent, retries + 1);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The one Team hard-limit translation: force a CompactionEngine reduction
|
|
159
|
+
* in the current Agent/Session and prove it advanced the durable surface
|
|
160
|
+
* (or measurably reduced pressure) before continuing. Background jobs are
|
|
161
|
+
* untouched — compaction never cancels or discards them.
|
|
162
|
+
* @returns whether the request may proceed.
|
|
163
|
+
*/
|
|
164
|
+
async enforceHardLimit(agent, memberId, sessionId, signal) {
|
|
165
|
+
const engine = this.options.compactionForAgent(agent);
|
|
166
|
+
if (engine === undefined) {
|
|
167
|
+
const diagnostic = 'context hard limit reached and compaction is unavailable in the Member scope; the request was blocked';
|
|
168
|
+
this.options.failed(memberId, sessionId, diagnostic);
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const meter = agent.ctx.get('tokenMeter');
|
|
172
|
+
const before = meter?.measure(agent.session)?.totalTokens ?? Number.POSITIVE_INFINITY;
|
|
173
|
+
const generation = agent.session.surface.replaceGeneration;
|
|
174
|
+
let result;
|
|
175
|
+
try {
|
|
176
|
+
result = await engine.compactIfNeeded(agent, 'context-overflow', signal);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
const diagnostic = `context hard limit compaction failed: ${error instanceof Error ? error.message : String(error)}; the request was blocked`;
|
|
180
|
+
this.options.failed(memberId, sessionId, diagnostic);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
if (signal.aborted)
|
|
184
|
+
return false;
|
|
185
|
+
const after = meter?.measure(agent.session)?.totalTokens ?? Number.POSITIVE_INFINITY;
|
|
186
|
+
const surfaceAdvanced = agent.session.surface.replaceGeneration > generation;
|
|
187
|
+
const pressureReduced = meter === undefined ? false : after < before;
|
|
188
|
+
if (!surfaceAdvanced && !pressureReduced) {
|
|
189
|
+
// No-op or unchanged replacement generation: fail closed rather than
|
|
190
|
+
// knowingly submit over the Team limit.
|
|
191
|
+
const diagnostic = result === null
|
|
192
|
+
? 'context hard limit reached and no compactable range exists; the request was blocked'
|
|
193
|
+
: 'context hard limit compaction produced no measurable reduction; the request was blocked';
|
|
194
|
+
this.options.failed(memberId, sessionId, diagnostic);
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* @module @wowyuarm/dsh-agent-team/progress-nudge
|
|
23
23
|
*/
|
|
24
24
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
25
|
-
|
|
25
|
+
import { AGENT_TEAM_PLUGIN_ID, isCheckpointContinuationMessage } from "./context-source.js";
|
|
26
26
|
/** Notice summary for every nudge this Coordinator injects. */
|
|
27
27
|
export const PROGRESS_NUDGE_NOTICE_SUMMARY = 'Progress visibility reminder';
|
|
28
28
|
export const PROGRESS_NUDGE_TOOL_CALLS_START = 20;
|
|
@@ -39,7 +39,7 @@ export function isPublicCommunicationOperationKind(kind) {
|
|
|
39
39
|
/** Whether one message is a nudge notice this module injected. */
|
|
40
40
|
export function isProgressNudgeNotice(message) {
|
|
41
41
|
const source = message.source;
|
|
42
|
-
return source.kind === 'plugin' && source.plugin ===
|
|
42
|
+
return source.kind === 'plugin' && source.plugin === AGENT_TEAM_PLUGIN_ID
|
|
43
43
|
&& source.form === 'notice' && source.summary === PROGRESS_NUDGE_NOTICE_SUMMARY;
|
|
44
44
|
}
|
|
45
45
|
/**
|
|
@@ -218,7 +218,7 @@ export class ProgressNudgeCoordinator {
|
|
|
218
218
|
const claimTargets = claimDue ? freshClaimTargets : [];
|
|
219
219
|
const notice = createUserMessage({
|
|
220
220
|
content: [{ type: 'text', text: nudgeNoticeText(state.silentToolCalls, progressTargets, claimTargets) }],
|
|
221
|
-
source: { kind: 'plugin', plugin:
|
|
221
|
+
source: { kind: 'plugin', plugin: AGENT_TEAM_PLUGIN_ID, form: 'notice', summary: PROGRESS_NUDGE_NOTICE_SUMMARY },
|
|
222
222
|
});
|
|
223
223
|
const pending = {
|
|
224
224
|
messageId: notice.id,
|
|
@@ -268,7 +268,15 @@ export class ProgressNudgeCoordinator {
|
|
|
268
268
|
hasBlockingNotice(agent) {
|
|
269
269
|
for (const message of [...agent.inbox.nextStep, ...agent.inbox.nextTurn]) {
|
|
270
270
|
const source = message.source;
|
|
271
|
-
if (source.kind !== 'plugin' || source.plugin !==
|
|
271
|
+
if (source.kind !== 'plugin' || source.plugin !== AGENT_TEAM_PLUGIN_ID)
|
|
272
|
+
continue;
|
|
273
|
+
// A queued checkpoint continuation is a pending wake that outranks a
|
|
274
|
+
// nudge exactly as the notice families above do. It rides the snapshot
|
|
275
|
+
// form (it must carry its checkpoint ref in a section), so it cannot be
|
|
276
|
+
// recognized by the notice summary check below.
|
|
277
|
+
if (isCheckpointContinuationMessage(message))
|
|
278
|
+
return true;
|
|
279
|
+
if (source.form !== 'notice')
|
|
272
280
|
continue;
|
|
273
281
|
if (source.summary !== PROGRESS_NUDGE_NOTICE_SUMMARY)
|
|
274
282
|
return true;
|