@aiwuk/team 0.0.0-stage → 5.0.8
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/CHANGELOG.md +175 -0
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/README.zh-CN.md +150 -0
- package/access.js +93 -0
- package/activity-summary.js +36 -0
- package/allocation.js +34 -0
- package/board-dispatch.js +141 -0
- package/brand.js +41 -0
- package/client/00-core-zh.js +284 -0
- package/client/01-i18n-en.js +265 -0
- package/client/02-styles.js +336 -0
- package/client/02b-stage-styles.js +65 -0
- package/client/03-shared.js +82 -0
- package/client/03b-collapsible-list.js +34 -0
- package/client/03c-config-panel.js +605 -0
- package/client/04-tool-cards.js +110 -0
- package/client/04a-dispatch-confirm.js +162 -0
- package/client/04b-more-cards.js +78 -0
- package/client/04c-model-picker.js +68 -0
- package/client/04d-confirm-dock.js +40 -0
- package/client/05-policy.js +217 -0
- package/client/06-usage.js +195 -0
- package/client/06b-model-source.js +67 -0
- package/client/07-board-parts.js +312 -0
- package/client/07a-role-theme.js +25 -0
- package/client/08-artifacts-report.js +375 -0
- package/client/08a-task-control.js +126 -0
- package/client/08b-stage-sprites.js +97 -0
- package/client/08c-stage-colors.js +55 -0
- package/client/08c-stage-scene.js +238 -0
- package/client/08d-stage-adapter.js +65 -0
- package/client/08e-stage-activity.js +83 -0
- package/client/09-team-board.js +181 -0
- package/client/09b-role-view.js +95 -0
- package/client/09c-model-tabs.js +20 -0
- package/client/10-overview.js +364 -0
- package/client/10b-onboarding.js +121 -0
- package/client/11-register.js +249 -0
- package/client.js +5053 -0
- package/config-projection.js +40 -0
- package/config.js +411 -0
- package/cordis.patch.yml +110 -0
- package/defaults.js +222 -0
- package/dispatch-confirm.js +293 -0
- package/dispatch-routes-store.js +31 -0
- package/failover.js +85 -0
- package/file-limits.js +119 -0
- package/free-worker.mjs +125 -0
- package/http-guard.js +14 -0
- package/i18n/roles.en.json +23 -0
- package/i18n/roles.zh.json +23 -0
- package/i18n/server.en.json +659 -0
- package/i18n/server.zh.json +659 -0
- package/i18n.js +34 -0
- package/icon.svg +7 -0
- package/index.js +113 -0
- package/integrations-config.js +301 -0
- package/integrations-messages.js +62 -0
- package/integrations.js +17 -0
- package/lead-route.js +30 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/long-context.js +30 -0
- package/managed-config-route.js +49 -0
- package/managed-ref.js +38 -0
- package/managed-resolve.js +47 -0
- package/member-models.js +84 -0
- package/member-task-control.js +242 -0
- package/memory-backends.js +89 -0
- package/memory-embed.js +70 -0
- package/memory-hub.js +140 -0
- package/memory-openviking.js +101 -0
- package/memory-redact.js +34 -0
- package/memory-store.js +270 -0
- package/memory-sync-queue.js +28 -0
- package/model-policy.js +85 -0
- package/model-prefs.js +104 -0
- package/observability-api.js +76 -0
- package/observability-projection.js +116 -0
- package/onboarding.js +47 -0
- package/opencode.js +74 -0
- package/package.json +155 -4
- package/plan-gate.js +92 -0
- package/preset-guard.js +47 -0
- package/request-evidence.js +41 -0
- package/roles.js +105 -0
- package/roster-defaults.js +70 -0
- package/route-health.js +39 -0
- package/runtime-observability.js +78 -0
- package/runtime-view.js +21 -0
- package/scripts/memory-cli.mjs +138 -0
- package/search-engines.js +119 -0
- package/search.js +164 -0
- package/session-policy.js +59 -0
- package/session-rotation.js +206 -0
- package/settings-schema.js +40 -0
- package/shared/managed-config.js +84 -0
- package/single-model.js +44 -0
- package/skills/project-role-theme/SKILL.md +46 -0
- package/task-bridge.js +32 -0
- package/task-control-api.js +36 -0
- package/task-control-audit.js +60 -0
- package/team.js +101 -0
- package/teammate-context.js +120 -0
- package/teammate-wait.js +18 -0
- package/template.js +46 -0
- package/theme-plugin.js +172 -0
- package/theme.js +115 -0
- package/token-discipline.js +116 -0
- package/token-metrics.js +37 -0
- package/tool-names.js +10 -0
- package/tool-schema.js +23 -0
- package/transcribe.js +104 -0
- package/ui.js +113 -0
- package/usage.js +96 -0
- package/workflow.js +246 -0
package/package.json
CHANGED
|
@@ -1,6 +1,157 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwuk/team",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "5.0.8",
|
|
4
|
+
"description": "AIWUK Team, a capability-routed multi-agent team preset for DeepSeek Harness (DSH) with per-role model routing, same-tier failover and conversational team configuration",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "walke2019",
|
|
7
|
+
"homepage": "https://github.com/walke2019/dsh-capability-team#readme",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/walke2019/dsh-capability-team.git" },
|
|
9
|
+
"bugs": { "url": "https://github.com/walke2019/dsh-capability-team/issues" },
|
|
10
|
+
"keywords": ["aiwuk", "dsh", "dsh-plugin", "deepseek-harness", "cordis", "agent-team", "multi-agent", "model-routing", "failover", "ai-agent", "llm"],
|
|
11
|
+
"engines": { "node": ">=24.9", "dsh": ">=0.2.2-rc.1 <0.3.0" },
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@deepseek-ai/cordis": ">=4.0.1",
|
|
14
|
+
"@deepseek-ai/dsh-agent-preset": ">=0.2.2-rc.1",
|
|
15
|
+
"@deepseek-ai/dsh-persona": ">=0.2.2-rc.1",
|
|
16
|
+
"@deepseek-ai/dsh-experimental-agent-team": ">=0.2.2-rc.1",
|
|
17
|
+
"@deepseek-ai/dsh-client-locale": ">=0.2.2-rc.1",
|
|
18
|
+
"@deepseek-ai/dsh-client-ui-conversation": ">=0.2.2-rc.1",
|
|
19
|
+
"@deepseek-ai/dsh-client-ui-tool": ">=0.2.2-rc.1",
|
|
20
|
+
"@deepseek-ai/dsh-api-remotes": ">=0.2.2-rc.1",
|
|
21
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right": ">=0.2.2-rc.1",
|
|
22
|
+
"@deepseek-ai/schemastery": ">=3.18.4"
|
|
23
|
+
},
|
|
24
|
+
"peerDependenciesMeta": {
|
|
25
|
+
"@deepseek-ai/cordis": { "optional": true },
|
|
26
|
+
"@deepseek-ai/dsh-agent-preset": { "optional": true },
|
|
27
|
+
"@deepseek-ai/dsh-persona": { "optional": true },
|
|
28
|
+
"@deepseek-ai/dsh-experimental-agent-team": { "optional": true },
|
|
29
|
+
"@deepseek-ai/dsh-client-locale": { "optional": true },
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-conversation": { "optional": true },
|
|
31
|
+
"@deepseek-ai/dsh-client-ui-tool": { "optional": true },
|
|
32
|
+
"@deepseek-ai/dsh-api-remotes": { "optional": true },
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right": { "optional": true },
|
|
34
|
+
"@deepseek-ai/schemastery": { "optional": true }
|
|
35
|
+
},
|
|
36
|
+
"type": "module",
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build:client": "node shared-ui/build.mjs && node scripts/build-client.mjs",
|
|
39
|
+
"pretest": "node shared-ui/build.mjs && node scripts/build-client.mjs && node packages/global-memory/scripts/build-shared.mjs && node packages/omni-search/scripts/build-shared.mjs && node packages/autocomplete/scripts/build-client.mjs",
|
|
40
|
+
"prepack": "node shared-ui/build.mjs && node scripts/build-client.mjs",
|
|
41
|
+
"test": "node --import ./tests/isolated-home.mjs --test tests/*.test.mjs && node --import ./tests/isolated-home.mjs --test tests/global-memory/*.test.mjs && node --import ./tests/isolated-home.mjs --test tests/omni-search/*.test.mjs && node --import ./tests/isolated-home.mjs --test tests/autocomplete/*.test.mjs",
|
|
42
|
+
"build:global-memory": "node packages/global-memory/scripts/build-shared.mjs",
|
|
43
|
+
"build:omni-search": "node packages/omni-search/scripts/build-shared.mjs",
|
|
44
|
+
"build:autocomplete": "node packages/autocomplete/scripts/build-client.mjs",
|
|
45
|
+
"lint": "node scripts/check.mjs syntax",
|
|
46
|
+
"build": "node scripts/check.mjs package"
|
|
47
|
+
},
|
|
48
|
+
"exports": {
|
|
49
|
+
".": "./ui.js",
|
|
50
|
+
"./client": "./client.js",
|
|
51
|
+
"./roles": "./index.js",
|
|
52
|
+
"./locale/*.json": "./locale/*.json",
|
|
53
|
+
"./i18n/*.json": "./i18n/*.json",
|
|
54
|
+
"./icon": "./icon.svg"
|
|
55
|
+
},
|
|
56
|
+
"files": [
|
|
57
|
+
"brand.js",
|
|
58
|
+
"i18n.js",
|
|
59
|
+
"roles.js",
|
|
60
|
+
"member-models.js",
|
|
61
|
+
"lead-route.js",
|
|
62
|
+
"config.js",
|
|
63
|
+
"roster-defaults.js",
|
|
64
|
+
"single-model.js",
|
|
65
|
+
"onboarding.js",
|
|
66
|
+
"settings-schema.js",
|
|
67
|
+
"team.js",
|
|
68
|
+
"failover.js",
|
|
69
|
+
"defaults.js",
|
|
70
|
+
"index.js",
|
|
71
|
+
"free-worker.mjs",
|
|
72
|
+
"integrations.js",
|
|
73
|
+
"search.js",
|
|
74
|
+
"search-engines.js",
|
|
75
|
+
"transcribe.js",
|
|
76
|
+
"task-bridge.js",
|
|
77
|
+
"theme.js",
|
|
78
|
+
"allocation.js",
|
|
79
|
+
"theme-plugin.js",
|
|
80
|
+
"tool-schema.js",
|
|
81
|
+
"tool-names.js",
|
|
82
|
+
"workflow.js",
|
|
83
|
+
"opencode.js",
|
|
84
|
+
"ui.js",
|
|
85
|
+
"runtime-view.js",
|
|
86
|
+
"model-policy.js",
|
|
87
|
+
"access.js",
|
|
88
|
+
"teammate-wait.js",
|
|
89
|
+
"usage.js",
|
|
90
|
+
"long-context.js",
|
|
91
|
+
"plan-gate.js",
|
|
92
|
+
"template.js",
|
|
93
|
+
"config-projection.js",
|
|
94
|
+
"session-policy.js",
|
|
95
|
+
"request-evidence.js",
|
|
96
|
+
"activity-summary.js",
|
|
97
|
+
"observability-projection.js",
|
|
98
|
+
"runtime-observability.js",
|
|
99
|
+
"observability-api.js",
|
|
100
|
+
"member-task-control.js",
|
|
101
|
+
"task-control-audit.js",
|
|
102
|
+
"task-control-api.js",
|
|
103
|
+
"client.js",
|
|
104
|
+
"client/*.js",
|
|
105
|
+
"file-limits.js",
|
|
106
|
+
"dispatch-confirm.js",
|
|
107
|
+
"dispatch-routes-store.js",
|
|
108
|
+
"session-rotation.js",
|
|
109
|
+
"memory-embed.js",
|
|
110
|
+
"memory-store.js",
|
|
111
|
+
"memory-hub.js",
|
|
112
|
+
"memory-openviking.js",
|
|
113
|
+
"memory-backends.js",
|
|
114
|
+
"preset-guard.js",
|
|
115
|
+
"token-metrics.js",
|
|
116
|
+
"memory-sync-queue.js",
|
|
117
|
+
"memory-redact.js",
|
|
118
|
+
"integrations-config.js",
|
|
119
|
+
"integrations-messages.js",
|
|
120
|
+
"managed-resolve.js",
|
|
121
|
+
"managed-ref.js",
|
|
122
|
+
"managed-config-route.js",
|
|
123
|
+
"shared/managed-config.js",
|
|
124
|
+
"http-guard.js",
|
|
125
|
+
"model-prefs.js",
|
|
126
|
+
"board-dispatch.js",
|
|
127
|
+
"teammate-context.js",
|
|
128
|
+
"token-discipline.js",
|
|
129
|
+
"route-health.js",
|
|
130
|
+
"skills/project-role-theme/SKILL.md",
|
|
131
|
+
"README.md",
|
|
132
|
+
"README.zh-CN.md",
|
|
133
|
+
"CHANGELOG.md",
|
|
134
|
+
"LICENSE",
|
|
135
|
+
"scripts/memory-cli.mjs",
|
|
136
|
+
"cordis.patch.yml",
|
|
137
|
+
"locale/*.json",
|
|
138
|
+
"i18n/*.json",
|
|
139
|
+
"icon.svg"
|
|
140
|
+
],
|
|
141
|
+
"dsh": {
|
|
142
|
+
"bundle": {
|
|
143
|
+
"patch": "./cordis.patch.yml"
|
|
144
|
+
},
|
|
145
|
+
"client": {
|
|
146
|
+
"platform": "web",
|
|
147
|
+
"immediately": true,
|
|
148
|
+
"inject": [
|
|
149
|
+
"@deepseek-ai/dsh-client-locale",
|
|
150
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
151
|
+
"@deepseek-ai/dsh-client-ui-tool",
|
|
152
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
153
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right"
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
package/plan-gate.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { m as modelText } from './i18n.js';
|
|
2
|
+
// Plan-first teammates: a capability with `planFirst` submits a plan to the Lead and waits for approval before it may
|
|
3
|
+
// run execution tools. A new dispatch plan that assigns the role asks for a fresh plan, so one approval covers one task.
|
|
4
|
+
import { params } from './tool-schema.js';
|
|
5
|
+
import { ownsCapabilityTeam } from './preset-guard.js';
|
|
6
|
+
import { resolveRole } from './config.js';
|
|
7
|
+
import { toolClass } from './workflow.js';
|
|
8
|
+
export const SUBMIT_TOOL = 'capability_submit_plan';
|
|
9
|
+
export const REVIEW_TOOL = 'capability_review_plan';
|
|
10
|
+
const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] };
|
|
11
|
+
const STATUS = { pending: modelText('plan-gate.324'), approved: modelText('plan-gate.325'), rejected: modelText('plan-gate.326') };
|
|
12
|
+
function text(value, max, label) {
|
|
13
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(modelText('plan-gate.327', { 0: label, 1: max }));
|
|
14
|
+
return value.trim();
|
|
15
|
+
}
|
|
16
|
+
/** Pure decision for one teammate call under plan-first; returns a denial reason or undefined. */
|
|
17
|
+
export function planDenial(exec, role, plan, workflow) {
|
|
18
|
+
if (!role.planFirst || exec.name === SUBMIT_TOOL || plan?.status === 'approved') return undefined;
|
|
19
|
+
const kind = toolClass(exec.name, workflow);
|
|
20
|
+
if (kind === 'analysis' || kind === 'coordination') return undefined;
|
|
21
|
+
const state = plan ? modelText('plan-gate.328', { 0: STATUS[plan.status], 1: plan.feedback ? modelText('plan-gate.329', { 0: plan.feedback }) : '' }) : modelText('plan-gate.330');
|
|
22
|
+
return modelText('plan-gate.331', { 0: role.displayName, 1: role.capability, 2: SUBMIT_TOOL, 3: exec.name, 4: state });
|
|
23
|
+
}
|
|
24
|
+
export function createPlanGate(ctx, team) {
|
|
25
|
+
const plans = new Map(); // `${teamId}:${role}` → { status, plan, feedback?, at }
|
|
26
|
+
const keyOf = (membership, role = membership.name) => `${membership.id}:${role}`;
|
|
27
|
+
const roleOf = (agent, membership) => {
|
|
28
|
+
const project = team.pinned(agent);
|
|
29
|
+
const effective = project ? team.loadSync(project, agent).effective : team.base;
|
|
30
|
+
return { role: resolveRole(membership, effective), effective };
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
plans,
|
|
34
|
+
/** Model-facing plan state for a teammate's own context, or undefined when its capability is not plan-first. */
|
|
35
|
+
status(agent, membership, role) {
|
|
36
|
+
if (!role?.planFirst || membership?.role !== 'teammate') return undefined;
|
|
37
|
+
const plan = plans.get(keyOf(membership));
|
|
38
|
+
return plan ? `${STATUS[plan.status]}${plan.feedback ? modelText('plan-gate.332', { 0: plan.feedback }) : ''}` : modelText('plan-gate.333', { 0: SUBMIT_TOOL });
|
|
39
|
+
},
|
|
40
|
+
/** The Lead assigned these roles in a new dispatch plan: earlier approvals do not cover the new task. */
|
|
41
|
+
reset(leadMembership, roles) {
|
|
42
|
+
for (const role of roles) plans.delete(keyOf(leadMembership, role));
|
|
43
|
+
},
|
|
44
|
+
guard(exec) {
|
|
45
|
+
const membership = ctx.agentTeams.tryMembership(exec.agent);
|
|
46
|
+
if (!ownsCapabilityTeam(ctx, exec.agent) || membership?.role !== 'teammate') return undefined;
|
|
47
|
+
let resolved;
|
|
48
|
+
try { resolved = roleOf(exec.agent, membership); } catch { return undefined; } // Routing and the access guard report config errors.
|
|
49
|
+
return planDenial(exec, resolved.role, plans.get(keyOf(membership)), resolved.effective.workflow);
|
|
50
|
+
},
|
|
51
|
+
register() {
|
|
52
|
+
ctx.effect(() => ctx.tools.guard(exec => this.guard(exec)));
|
|
53
|
+
ctx.effect(() => ctx.tools.register({ name: SUBMIT_TOOL, output,
|
|
54
|
+
description: modelText('plan-gate.334'),
|
|
55
|
+
parameters: params({ plan: { type: 'string', description: modelText('plan-gate.335') } }, ['plan']),
|
|
56
|
+
async execute(args, exec) {
|
|
57
|
+
const membership = ctx.agentTeams.membership(exec.agent);
|
|
58
|
+
if (membership.role !== 'teammate') throw new Error(modelText('plan-gate.336'));
|
|
59
|
+
const { role } = roleOf(exec.agent, membership);
|
|
60
|
+
if (!role.planFirst) throw new Error(modelText('plan-gate.337', { 0: role.displayName }));
|
|
61
|
+
const plan = text(args.plan, 8000, modelText('plan-gate.338'));
|
|
62
|
+
plans.set(keyOf(membership), { status: 'pending', plan, at: Date.now() });
|
|
63
|
+
await ctx.agentTeams.sendMessage(exec.agent, { target: 'lead', signal: exec.signal, content: [{ type: 'text',
|
|
64
|
+
text: modelText('plan-gate.339', { 0: role.displayName, 1: membership.name, 2: REVIEW_TOOL, 3: membership.name, 4: plan }) }] });
|
|
65
|
+
return modelText('plan-gate.340', { 0: STATUS.pending });
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
68
|
+
ctx.effect(() => ctx.tools.register({ name: REVIEW_TOOL, output,
|
|
69
|
+
description: modelText('plan-gate.341'),
|
|
70
|
+
parameters: params({
|
|
71
|
+
role: { type: 'string', description: modelText('plan-gate.342') },
|
|
72
|
+
approve: { type: 'boolean', description: modelText('plan-gate.343') },
|
|
73
|
+
feedback: { type: 'string', description: modelText('plan-gate.344') },
|
|
74
|
+
}, ['role', 'approve']),
|
|
75
|
+
async execute(args, exec) {
|
|
76
|
+
const membership = ctx.agentTeams.membership(exec.agent);
|
|
77
|
+
if (membership.role !== 'lead') throw new Error(modelText('plan-gate.345'));
|
|
78
|
+
const role = text(args.role, 64, modelText('plan-gate.346'));
|
|
79
|
+
const plan = plans.get(keyOf(membership, role));
|
|
80
|
+
if (!plan || plan.status !== 'pending') throw new Error(modelText('plan-gate.347', { 0: role }));
|
|
81
|
+
if (args.approve !== true && args.approve !== false) throw new Error(modelText('plan-gate.348'));
|
|
82
|
+
const feedback = args.feedback === undefined || args.feedback === '' ? undefined : text(args.feedback, 2000, modelText('plan-gate.349'));
|
|
83
|
+
if (!args.approve && !feedback) throw new Error(modelText('plan-gate.350'));
|
|
84
|
+
plans.set(keyOf(membership, role), { ...plan, status: args.approve ? 'approved' : 'rejected', feedback, at: Date.now() });
|
|
85
|
+
await ctx.agentTeams.sendMessage(exec.agent, { target: role, signal: exec.signal, content: [{ type: 'text',
|
|
86
|
+
text: args.approve ? modelText('plan-gate.351', { 0: feedback ? modelText('plan-gate.352', { 0: feedback }) : '' }) : modelText('plan-gate.353', { 0: SUBMIT_TOOL, 1: feedback }) }] });
|
|
87
|
+
return modelText('plan-gate.354', { 0: args.approve ? modelText('plan-gate.355') : modelText('plan-gate.356'), 1: role });
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/preset-guard.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Fail closed: native team membership alone does not identify this preset.
|
|
2
|
+
export const CAPABILITY_PRESET = 'capability-team';
|
|
3
|
+
// A service missing from the caller's `inject` throws on direct property access in cordis; ctx.get() does not.
|
|
4
|
+
const service = (ctx, name) => { try { const direct = ctx[name]; if (direct) return direct; } catch { /* not injected */ } try { return ctx.get?.(name); } catch { return undefined; } };
|
|
5
|
+
export function ownsCapabilityTeam(ctx, agent) {
|
|
6
|
+
if (!agent) return false;
|
|
7
|
+
try {
|
|
8
|
+
const membership = service(ctx, 'agentTeams')?.tryMembership(agent);
|
|
9
|
+
const owner = membership?.root ?? agent;
|
|
10
|
+
const presets = service(ctx, 'agentPresets');
|
|
11
|
+
return presets?.composedPreset(owner.ctx) === CAPABILITY_PRESET;
|
|
12
|
+
} catch { return false; }
|
|
13
|
+
}
|
|
14
|
+
export function capabilitySession(ctx, session) {
|
|
15
|
+
try { return ownsCapabilityTeam(ctx, ctx.agents?.get(session?.id)); }
|
|
16
|
+
catch { return false; }
|
|
17
|
+
}
|
|
18
|
+
export function isolatedProjection(ctx, definition) {
|
|
19
|
+
const owns = header => {
|
|
20
|
+
// DSH's own SessionHeader.agentPreset decides first. The live Agent is only a fallback for headers without it:
|
|
21
|
+
// a projection can initialise before the Agent's preset is composed, and that early "not ours" used to stick
|
|
22
|
+
// for the whole session, leaving usage, activity and report empty for real capability-team sessions.
|
|
23
|
+
const recorded = header?.agentPreset ?? header?.preset;
|
|
24
|
+
if (recorded !== undefined) return recorded === CAPABILITY_PRESET;
|
|
25
|
+
try {
|
|
26
|
+
const agent = ctx.agents?.get(header?.id ?? header?.sessionId);
|
|
27
|
+
return agent ? ownsCapabilityTeam(ctx, agent) : false;
|
|
28
|
+
} catch { return false; }
|
|
29
|
+
};
|
|
30
|
+
// +2: checkpoints written by the earlier decision are discarded and refolded from the session log.
|
|
31
|
+
return { ...definition, stateVersion: definition.stateVersion + 2,
|
|
32
|
+
init(header, inherited) {
|
|
33
|
+
const state = definition.init(header, inherited);
|
|
34
|
+
return { ...state, capabilityPreset: owns(header) };
|
|
35
|
+
},
|
|
36
|
+
apply(state, event) {
|
|
37
|
+
// A blank session may switch preset before its first turn: re-decide from the committed selection.
|
|
38
|
+
if (event?.type === 'agent-preset/selected') {
|
|
39
|
+
const owned = event.data?.agentPreset === CAPABILITY_PRESET;
|
|
40
|
+
if (owned !== state.capabilityPreset) return { ...state, capabilityPreset: owned };
|
|
41
|
+
}
|
|
42
|
+
if (!state.capabilityPreset) return state;
|
|
43
|
+
return { ...definition.apply(state, event), capabilityPreset: true };
|
|
44
|
+
},
|
|
45
|
+
wire: { ...definition.wire, view: state => definition.wire.view(state.capabilityPreset ? state : definition.init()) },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { m as modelText } from './i18n.js';
|
|
2
|
+
// Extract only recorded model identities, never prompt/content/replayState.
|
|
3
|
+
// Request headers prove configuration admission, not successful provider completion.
|
|
4
|
+
import { foldUsage } from './usage.js';
|
|
5
|
+
export function requestEvidence(snapshot, limit = 30) {
|
|
6
|
+
const inherited = snapshot.inheritedEventCount ?? 0;
|
|
7
|
+
const result = [];
|
|
8
|
+
for (let i = snapshot.events.length - 1; i >= inherited && result.length < limit; i--) {
|
|
9
|
+
const event = snapshot.events[i];
|
|
10
|
+
let source, evidence;
|
|
11
|
+
if (event.type === 'request/header') { source = event.data?.header?.config; evidence = 'request-header'; }
|
|
12
|
+
else if (event.type === 'assistant/message') { source = event.data?.message?.source; evidence = 'assistant-message'; }
|
|
13
|
+
if (typeof source?.provider !== 'string' || typeof source?.model !== 'string') continue;
|
|
14
|
+
result.push({ seq: event.seq, time: event.time, provider: source.provider, model: source.model, evidence,
|
|
15
|
+
...(event.data?.interrupted ? { interrupted: true } : {}) });
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
export async function readTeamEvidence(query, teams, agent) {
|
|
20
|
+
let members;
|
|
21
|
+
try { members = teams ? teams.listMembers(agent) : [{ id: agent.id, name: 'lead' }]; }
|
|
22
|
+
catch (error) {
|
|
23
|
+
return [{ sessionId: agent.id, name: 'lead', ...await readRequestEvidence(query, agent.id),
|
|
24
|
+
unavailable: modelText('request-evidence.357', { 0: error.message ?? error }) }];
|
|
25
|
+
}
|
|
26
|
+
const result = [];
|
|
27
|
+
// Deliberately sequential: bound peak log memory to one member at a time.
|
|
28
|
+
for (const member of members) {
|
|
29
|
+
result.push({ sessionId: member.id, name: member.name, inheritedModel: member.model ?? null,
|
|
30
|
+
...await readRequestEvidence(query, member.id) });
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
export async function readRequestEvidence(query, sessionId) {
|
|
35
|
+
if (!query) return { records: [], unavailable: modelText('request-evidence.358') };
|
|
36
|
+
try {
|
|
37
|
+
const snapshot = await query.readSession(sessionId);
|
|
38
|
+
return { records: requestEvidence(snapshot), usage: foldUsage(snapshot) };
|
|
39
|
+
}
|
|
40
|
+
catch (error) { return { records: [], unavailable: String(error.message ?? error) }; }
|
|
41
|
+
}
|
package/roles.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { m as modelText, roleText } from './i18n.js';
|
|
2
|
+
import { allocatedRequest } from './allocation.js';
|
|
3
|
+
import { followUserChoice, leadRequest } from './lead-route.js';
|
|
4
|
+
import { budgetDenial, sessionUsage } from './usage.js';
|
|
5
|
+
import { createLongContext, promptTokens } from './long-context.js';
|
|
6
|
+
import { ensurePreset } from './access.js';
|
|
7
|
+
import { bindRuntime } from './runtime-observability.js';
|
|
8
|
+
import { FILE_LIMITS, fileLimitRules } from './file-limits.js';
|
|
9
|
+
import { TOKEN_RULES, tokenRuleText } from './token-discipline.js';
|
|
10
|
+
// `allowed` is the free list resolved from OpenCode's catalog (opencode.js); nothing is fixed here.
|
|
11
|
+
export function freeArgs(prompt, model, allowed = []) {
|
|
12
|
+
if (typeof model !== 'string' || !allowed.includes(model)) throw new Error(modelText('roles.359'));
|
|
13
|
+
if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 16000) throw new Error(modelText('roles.360'));
|
|
14
|
+
return ['run', '--format', 'json', '--model', model, '--', prompt];
|
|
15
|
+
}
|
|
16
|
+
// Static rules hold no role names, models or tiers: those come from the per-Agent team context.
|
|
17
|
+
export const policy = roleText('roles.361', { 0: fileLimitRules(FILE_LIMITS), 1: tokenRuleText(TOKEN_RULES) });
|
|
18
|
+
/**
|
|
19
|
+
* Route every model request by the native Team identity.
|
|
20
|
+
* The loop re-runs agent/request for each retry, so the failover state picks the attempt's route.
|
|
21
|
+
*/
|
|
22
|
+
export function apply(ctx, team, failover, observability, taskControl) {
|
|
23
|
+
const installed = new WeakSet();
|
|
24
|
+
const installPolicy = agent => {
|
|
25
|
+
if (!team.owns(agent) || installed.has(agent)) return;
|
|
26
|
+
installed.add(agent);
|
|
27
|
+
agent.ctx.systemPrompt.context({ name: 'capability-team:policy', order: 95, text: () => team.owns(agent) ? policy : '' });
|
|
28
|
+
};
|
|
29
|
+
ctx.on('agent/created', async ({ agent }) => { installPolicy(agent); await preselect(ctx, team, failover, agent); });
|
|
30
|
+
for (const agent of ctx.agents?.list?.() ?? []) installPolicy(agent);
|
|
31
|
+
const longContext = createLongContext();
|
|
32
|
+
const hadOverride = new WeakSet();
|
|
33
|
+
ctx.on('agent/request', async ({ agent, turn, step }, next) => {
|
|
34
|
+
const previous = await next();
|
|
35
|
+
if (!team.owns(agent)) return previous;
|
|
36
|
+
const resolved = await team.resolve(agent);
|
|
37
|
+
const lead = ctx.agentTeams?.tryMembership?.(agent)?.role === 'lead';
|
|
38
|
+
const { leadContext, ...configuredRole } = resolved;
|
|
39
|
+
// The Lead uses the model the user picked in the conversation; teammates are routed strictly by role.
|
|
40
|
+
const role = lead ? followUserChoice(configuredRole, previous, leadContext) : configuredRole;
|
|
41
|
+
if (role.userChoiceRejected) ctx.logger?.warn?.(modelText('roles.362', { 0: role.userChoiceRejected }));
|
|
42
|
+
// Fail closed: silently inheriting the Lead model defeats capability tiers and hides misconfiguration.
|
|
43
|
+
if (role.unassigned) throw new Error(modelText('roles.363', { 0: role.displayName ?? role.id, 1: role.capability }));
|
|
44
|
+
// DSH's own sandbox preset for read-only roles; re-checked every request so config edits take effect.
|
|
45
|
+
ensurePreset(ctx.get?.('permissionPresets'), agent.session, role.access, message => ctx.logger?.warn?.(`capability-team: ${message}`));
|
|
46
|
+
const projections = ctx.get?.('sessionProjections');
|
|
47
|
+
const overBudget = budgetDenial(role, sessionUsage(projections, agent.session));
|
|
48
|
+
if (overBudget) throw new Error(overBudget);
|
|
49
|
+
const controlled = taskControl ? await taskControl.route(agent, role) : role;
|
|
50
|
+
if (hadOverride.has(agent) && !controlled.taskLeaseSignature) { longContext.reset(agent.session); hadOverride.delete(agent); }
|
|
51
|
+
if (controlled.taskLeaseSignature) hadOverride.add(agent);
|
|
52
|
+
const routed = controlled.taskLeaseSignature ? controlled : longContext.apply(agent.session, controlled, promptTokens(projections, agent.session));
|
|
53
|
+
// Always explicit: the loop pins the last logged header, so the primary must be restored by name.
|
|
54
|
+
const selected = failover.route(agent, turn, step, routed);
|
|
55
|
+
if (observability) {
|
|
56
|
+
try {
|
|
57
|
+
bindRuntime(agent, observability, ctx.agentTeams);
|
|
58
|
+
observability.request(agent, { turn, step, role, routed, selected });
|
|
59
|
+
} catch { /* telemetry must never change model selection */ }
|
|
60
|
+
}
|
|
61
|
+
// DSH fixes provider/model before step preparation; a different route can only take effect from the next step.
|
|
62
|
+
if (agent.model?.select && !sameRoute(previous, selected)) {
|
|
63
|
+
agent.model.select({ provider: selected.provider, model: selected.model });
|
|
64
|
+
ctx.logger?.info?.(`capability-team: ${previous.provider}/${previous.model} -> ${selected.provider}/${selected.model} from the next step`);
|
|
65
|
+
return previous;
|
|
66
|
+
}
|
|
67
|
+
if (agent.model?.select) return (lead && leadRequest(previous, selected)) || keepEffort(previous, allocatedRequest(previous, selected));
|
|
68
|
+
return (lead && leadRequest(previous, selected)) || allocatedRequest(previous, selected);
|
|
69
|
+
});
|
|
70
|
+
ctx.on('agent/request-error', async ({ agent, failure, signal }, next) => {
|
|
71
|
+
const downstream = await next(); // DSH provider retry policy keeps priority.
|
|
72
|
+
if (team.owns(agent)) { try { observability?.failure(agent, failure); } catch { /* read-only telemetry */ } }
|
|
73
|
+
if (downstream?.kind === 'retry' || !team.owns(agent)) return downstream;
|
|
74
|
+
const before = failover.status(agent)?.active;
|
|
75
|
+
if (!(await failover.recover(agent, failure, signal))) return undefined;
|
|
76
|
+
const after = failover.status(agent)?.active;
|
|
77
|
+
// A retry reuses the step's fixed route, so a switch is recorded for the next step instead of retried here.
|
|
78
|
+
if (agent.model?.select && after && after !== before) {
|
|
79
|
+
const slash = after.indexOf('/');
|
|
80
|
+
agent.model.select({ provider: after.slice(0, slash), model: after.slice(slash + 1) });
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
return { kind: 'retry' };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const sameRoute = (a, b) => a?.provider === b?.provider && a?.model === b?.model;
|
|
87
|
+
/** Same route as the step's selection: its reasoning effort is part of the fixed selection and must be kept. */
|
|
88
|
+
function keepEffort(previous, request) {
|
|
89
|
+
return previous.reasoningEffort === undefined ? request : { ...request, reasoningEffort: previous.reasoningEffort };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Select a teammate's role route when it is created, before its first step fixes a model. Without this the
|
|
93
|
+
* teammate starts on the inherited Lead model and DSH rejects the per-role route in agent/request.
|
|
94
|
+
*/
|
|
95
|
+
export async function preselect(ctx, team, failover, agent) {
|
|
96
|
+
try {
|
|
97
|
+
if (!agent?.model?.select || !team.owns(agent) || ctx.agentTeams?.tryMembership?.(agent)?.role !== 'teammate') return;
|
|
98
|
+
const role = await team.resolve(agent);
|
|
99
|
+
if (role.unassigned || !role.provider) return; // agent/request fails closed with guidance
|
|
100
|
+
const route = failover.route(agent, 0, 0, role);
|
|
101
|
+
if (!sameRoute(agent.model.current?.(), route)) agent.model.select({ provider: route.provider, model: route.model });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
ctx.logger?.warn?.(`capability-team: route preselection failed: ${String(error?.message ?? error)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { roleText, m as modelText } from './i18n.js';
|
|
2
|
+
// Built-in role rosters. The neutral roster is the default; "three-kingdoms" is an optional skin applied
|
|
3
|
+
// per project through capability_team_import { theme }. Projects saved before 4.0 keep their hero roster.
|
|
4
|
+
const isMap = v => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
5
|
+
const frozen = roles => Object.freeze(Object.fromEntries(Object.entries(roles).map(([id, r]) => [id, Object.freeze(r)])));
|
|
6
|
+
/** Neutral default roster (English display names; the Web client localizes untouched defaults). */
|
|
7
|
+
export const DEFAULT_ROLES = frozen({
|
|
8
|
+
lead: { displayName: 'Lead', capability: 'orchestrator' },
|
|
9
|
+
architect: { displayName: 'Architect', capability: 'technical-director' },
|
|
10
|
+
backend: { displayName: 'Backend', capability: 'backend' },
|
|
11
|
+
frontend: { displayName: 'Frontend', capability: 'frontend' },
|
|
12
|
+
researcher: { displayName: 'Researcher', capability: 'research' },
|
|
13
|
+
writer: { displayName: 'Writer', capability: 'writer' },
|
|
14
|
+
qa: { displayName: 'QA', capability: 'qa' },
|
|
15
|
+
});
|
|
16
|
+
/** The pre-4.0 default roster, kept as the optional "three-kingdoms" theme. */
|
|
17
|
+
export const THREE_KINGDOMS_ROLES = frozen({
|
|
18
|
+
lead: { displayName: roleText('roster-defaults.364'), capability: 'orchestrator' },
|
|
19
|
+
'zhuge-liang': { displayName: roleText('roster-defaults.365'), capability: 'technical-director' },
|
|
20
|
+
'lv-bu': { displayName: roleText('roster-defaults.366'), capability: 'backend' },
|
|
21
|
+
'diao-chan': { displayName: roleText('roster-defaults.367'), capability: 'frontend' },
|
|
22
|
+
'zhao-yun': { displayName: roleText('roster-defaults.368'), capability: 'research' },
|
|
23
|
+
'chen-lin': { displayName: roleText('roster-defaults.369'), capability: 'writer' },
|
|
24
|
+
'zhou-yu': { displayName: roleText('roster-defaults.370'), capability: 'qa' },
|
|
25
|
+
});
|
|
26
|
+
export const ROLE_THEMES = Object.freeze({ default: DEFAULT_ROLES, 'three-kingdoms': THREE_KINGDOMS_ROLES });
|
|
27
|
+
/** Hero id → neutral id with the same capability; lets members spawned under the old roster keep routing. */
|
|
28
|
+
export const LEGACY_ROLE_ALIASES = Object.freeze({ 'zhuge-liang': 'architect', 'lv-bu': 'backend', 'diao-chan': 'frontend', 'zhao-yun': 'researcher', 'chen-lin': 'writer', 'zhou-yu': 'qa' });
|
|
29
|
+
const sameRole = (a, b) => isMap(a) && isMap(b) && a.displayName === b.displayName && a.capability === b.capability && a.description === b.description;
|
|
30
|
+
/** Stored-override marker written by 4.0+: such files are never read against the old hero default. */
|
|
31
|
+
export const ROSTER_MARK = 2;
|
|
32
|
+
/**
|
|
33
|
+
* True for overrides saved before 4.0 against the hero default: no marker (every 4.0+ save writes one), and a hero id
|
|
34
|
+
* the base lacks is stored, whether as a partial rename, a deletion (null) or a complete role from a 3.x template.
|
|
35
|
+
* Such a file was written on top of the hero roster, so mixing it into the neutral default would duplicate roles.
|
|
36
|
+
*/
|
|
37
|
+
export function isLegacyRoster(baseRoles = {}, overrides = {}) {
|
|
38
|
+
if (overrides.roster === ROSTER_MARK) return false;
|
|
39
|
+
return Object.keys(overrides.roles ?? {}).some(id => Object.hasOwn(LEGACY_ROLE_ALIASES, id) && !Object.hasOwn(baseRoles, id));
|
|
40
|
+
}
|
|
41
|
+
/** Base roles for one project's stored overrides: a pre-4.0 save keeps the hero roster (neutral defaults it never saw are dropped). */
|
|
42
|
+
export function rosterBase(baseRoles = {}, overrides = {}) {
|
|
43
|
+
if (!isLegacyRoster(baseRoles, overrides)) return baseRoles;
|
|
44
|
+
const kept = Object.entries(baseRoles).filter(([id, r]) => !(Object.hasOwn(DEFAULT_ROLES, id) && sameRole(r, DEFAULT_ROLES[id])));
|
|
45
|
+
return { ...Object.fromEntries(kept), ...THREE_KINGDOMS_ROLES };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Overrides to store after a save: marked as 4.0+. A legacy project's roster is written out explicitly on its first
|
|
49
|
+
* save (base-only roles stored as null), so later loads no longer depend on guessing.
|
|
50
|
+
*/
|
|
51
|
+
export function pinRoster(next, previous, baseRoles = {}) {
|
|
52
|
+
if (!isLegacyRoster(baseRoles, previous)) return { ...next, roster: ROSTER_MARK };
|
|
53
|
+
const roles = Object.fromEntries(Object.keys(baseRoles).map(id => [id, null]));
|
|
54
|
+
for (const [id, r] of Object.entries(rosterBase(baseRoles, previous))) roles[id] = { ...r };
|
|
55
|
+
for (const [id, r] of Object.entries(next.roles ?? {})) roles[id] = r === null ? null : isMap(r) && isMap(roles[id]) ? { ...roles[id], ...r } : r;
|
|
56
|
+
return { ...next, roles, roster: ROSTER_MARK };
|
|
57
|
+
}
|
|
58
|
+
/** Role id a member name resolves to when the roster lacks it: a legacy hero name maps to its neutral role. */
|
|
59
|
+
export function legacyRoleId(name, roles) {
|
|
60
|
+
const alias = LEGACY_ROLE_ALIASES[name];
|
|
61
|
+
return alias && !Object.hasOwn(roles, name) && Object.hasOwn(roles, alias) ? alias : undefined;
|
|
62
|
+
}
|
|
63
|
+
/** Save patch that replaces the project's roster with a built-in theme; other roles are removed, per-role descriptions kept. */
|
|
64
|
+
export function themePatch(theme, current) {
|
|
65
|
+
const roles = Object.hasOwn(ROLE_THEMES, theme) ? ROLE_THEMES[theme] : undefined;
|
|
66
|
+
if (!roles) throw new Error(modelText('roster-defaults.371', { 0: theme, 1: Object.keys(ROLE_THEMES).join(' / ') }));
|
|
67
|
+
const patch = Object.fromEntries(Object.entries(roles).map(([id, r]) => [id, { ...r }]));
|
|
68
|
+
for (const id of Object.keys(current.roles ?? {})) if (!Object.hasOwn(patch, id)) patch[id] = false;
|
|
69
|
+
return { roles: patch };
|
|
70
|
+
}
|
package/route-health.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Route failures that outlive a DSH restart. The failover layer keeps cooldowns in memory, so after a restart
|
|
2
|
+
// a route with a dead key looked healthy again and could even be preselected as a habit. Failures are kept
|
|
3
|
+
// here: credential errors (the key is wrong until someone fixes it) for a day, other failures for an hour.
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
export const CREDENTIAL_CODES = new Set(['AUTH', 'INVALID_CREDENTIAL', 'MISSING_CREDENTIAL']);
|
|
7
|
+
export const KEEP_MS = { credential: 24 * 3600_000, other: 3600_000 };
|
|
8
|
+
/** Whether a failure means the route's key is wrong or missing (it stays bad until someone fixes it). */
|
|
9
|
+
export const isCredential = (code, status) => CREDENTIAL_CODES.has(code) || status === 401 || status === 403;
|
|
10
|
+
export function createRouteHealth(root, { now = Date.now } = {}) {
|
|
11
|
+
const path = join(root, 'route-health.json');
|
|
12
|
+
let data;
|
|
13
|
+
const load = () => {
|
|
14
|
+
if (data) return data;
|
|
15
|
+
try { data = JSON.parse(readFileSync(path, 'utf8')); } catch { data = {}; }
|
|
16
|
+
return data;
|
|
17
|
+
};
|
|
18
|
+
const save = () => {
|
|
19
|
+
try { mkdirSync(root, { recursive: true }); const temp = `${path}.${process.pid}.tmp`; writeFileSync(temp, JSON.stringify(data)); renameSync(temp, path); } catch { /* best effort */ }
|
|
20
|
+
};
|
|
21
|
+
const alive = (entry, at) => at - entry.lastFailureAt < (entry.credential ? KEEP_MS.credential : KEEP_MS.other);
|
|
22
|
+
return {
|
|
23
|
+
/** Failover telemetry: remember each cooldown with its error class. */
|
|
24
|
+
record(event) {
|
|
25
|
+
if (event?.kind !== 'cooldown' || !event.route) return;
|
|
26
|
+
const key = `${event.route.provider}/${event.route.model}`;
|
|
27
|
+
load()[key] = { strikes: event.strikes ?? 1, lastFailureAt: now(), code: event.code ?? null, credential: isCredential(event.code, event.status) };
|
|
28
|
+
for (const [k, v] of Object.entries(data)) if (!alive(v, now())) delete data[k];
|
|
29
|
+
save();
|
|
30
|
+
},
|
|
31
|
+
/** Recent failures, in the same shape as failover.health(). */
|
|
32
|
+
recent() {
|
|
33
|
+
const at = now();
|
|
34
|
+
return Object.fromEntries(Object.entries(load()).filter(([, v]) => alive(v, at)).map(([k, v]) => [k, { strikes: v.strikes, lastFailureAt: v.lastFailureAt, cooling: false, credential: v.credential }]));
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Persisted failures merged with the live failover state (live state wins). */
|
|
39
|
+
export const mergeHealth = (persisted, live) => ({ ...persisted, ...live });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Ephemeral routing telemetry: no prompt/error text and no persisted session events.
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { safeId, safeTime } from './activity-summary.js';
|
|
4
|
+
import { taskControlSnapshot } from './member-task-control.js';
|
|
5
|
+
const routeOf = r => r && typeof r.provider === 'string' && r.provider.length <= 128 && typeof r.model === 'string' && r.model.length <= 512 ? { provider: r.provider, model: r.model } : null;
|
|
6
|
+
const same = (a, b) => a && b && a.provider === b.provider && a.model === b.model;
|
|
7
|
+
const codeOf = c => safeId(c, 80);
|
|
8
|
+
const statusOf = s => Number.isInteger(s) && s >= 100 && s <= 599 ? s : null;
|
|
9
|
+
export function createRuntimeObservability({ now = Date.now, epoch = randomUUID() } = {}) {
|
|
10
|
+
const since = safeTime(now()) ?? 0;
|
|
11
|
+
const members = new Map(), listeners = new Set(), sharedCooling = new Map(); let revision = 0;
|
|
12
|
+
const changed = () => { revision += 1; for (const listener of listeners) { try { listener(); } catch { /* observation must not affect routing */ } } };
|
|
13
|
+
const get = agent => {
|
|
14
|
+
const id = safeId(agent?.id); if (!id) return null;
|
|
15
|
+
if (!members.has(id)) {
|
|
16
|
+
if (members.size >= 256) members.delete(members.keys().next().value);
|
|
17
|
+
members.set(id, { sessionId: id, candidates: [], cooling: [], switches: [], counters: { requestFailures: 0, pluginRetries: 0, failoverSwitches: 0, longContextSwitches: 0 }, attempt: 0, turn: null, step: null, active: null, lc: false, truncated: false });
|
|
18
|
+
}
|
|
19
|
+
return members.get(id);
|
|
20
|
+
};
|
|
21
|
+
const record = (m, from, to, reason, detail = {}) => {
|
|
22
|
+
if (!from || !to || same(from, to)) return;
|
|
23
|
+
const at = safeTime(now()) ?? 0;
|
|
24
|
+
m.switches.unshift({ id: `${epoch}:${revision + 1}:${m.sessionId}`, at, turn: m.turn, step: m.step, attempt: m.attempt, from, to, reason, code: codeOf(detail.code), status: statusOf(detail.status) });
|
|
25
|
+
if (m.switches.length > 16) { m.switches.length = 16; m.truncated = true; }
|
|
26
|
+
if (reason === 'failover' || reason === 'long-context') m.counters[reason === 'failover' ? 'failoverSwitches' : 'longContextSwitches'] += 1;
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
epoch,
|
|
30
|
+
notifyTaskControl() { changed(); },
|
|
31
|
+
onChanged(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
32
|
+
request(agent, { turn, step, role, routed, selected }) {
|
|
33
|
+
const m = get(agent); if (!m) return;
|
|
34
|
+
const newStep = m.turn !== turn || m.step !== step;
|
|
35
|
+
m.attempt = !newStep ? m.attempt + 1 : 1;
|
|
36
|
+
m.turn = Number.isSafeInteger(turn) ? turn : null; m.step = Number.isSafeInteger(step) ? step : null;
|
|
37
|
+
m.candidates = [role, ...(role?.fallbacks ?? []), ...(role?.longContext ? [role.longContext] : [])].map(routeOf).filter(Boolean).slice(0, 32);
|
|
38
|
+
const active = routeOf(selected), lc = !!routed.longContextActive;
|
|
39
|
+
if (m.active && m.override !== (routed.taskLeaseSignature ?? null)) record(m, m.active, active, routed.taskLeaseSignature ? 'override' : 'override-expired');
|
|
40
|
+
m.override = routed.taskLeaseSignature ?? null;
|
|
41
|
+
if (m.active && !routed.taskLeaseSignature && lc !== m.lc) record(m, m.active, routeOf(routed), 'long-context');
|
|
42
|
+
const primary = routeOf(routed);
|
|
43
|
+
if (primary && active && !same(primary, active)) {
|
|
44
|
+
// recover already recorded a switch in this attempt; a new step skipping a cooled route has not.
|
|
45
|
+
if (m.active && newStep && (!m.pendingSwitch || !same(m.pendingSwitch, active))) record(m, primary, active, 'failover', [...sharedCooling.values()].find(c => same(c.route, primary)));
|
|
46
|
+
}
|
|
47
|
+
m.pendingSwitch = null; m.active = active; m.lc = lc; changed();
|
|
48
|
+
},
|
|
49
|
+
failure(agent, failure) { const m = get(agent); if (!m) return; m.counters.requestFailures += 1; changed(); },
|
|
50
|
+
telemetry(event) {
|
|
51
|
+
const m = get(event.agent); if (!m) return;
|
|
52
|
+
if (event.kind === 'retry') m.counters.pluginRetries += 1;
|
|
53
|
+
if (event.kind === 'cooldown') {
|
|
54
|
+
const route = routeOf(event.route); if (!route) return;
|
|
55
|
+
const cooldown = { route, code: codeOf(event.code), status: statusOf(event.status), until: safeTime(event.until), attempt: m.attempt };
|
|
56
|
+
// Shared cooldown is filtered by each member's confirmed candidates at snapshot time.
|
|
57
|
+
sharedCooling.set(JSON.stringify(route), cooldown);
|
|
58
|
+
if (sharedCooling.size > 256) sharedCooling.delete(sharedCooling.keys().next().value);
|
|
59
|
+
}
|
|
60
|
+
if (event.kind === 'switch') { const to = routeOf(event.to); record(m, routeOf(event.from), to, 'failover', event); m.pendingSwitch = to; }
|
|
61
|
+
changed();
|
|
62
|
+
},
|
|
63
|
+
snapshot(leadSessionId, roster) {
|
|
64
|
+
const result = [];
|
|
65
|
+
for (const member of roster.slice(0, 64)) {
|
|
66
|
+
const m = members.get(member.id);
|
|
67
|
+
const candidates = m?.candidates ?? [];
|
|
68
|
+
const cooling = [...sharedCooling.values()].filter(c => c.until > now() && candidates.some(r => same(c.route, r)));
|
|
69
|
+
result.push({ sessionId: member.id, cooling: cooling.slice(0, 16).map(c => ({ ...c, route: { ...c.route } })), switches: (m?.switches ?? []).map(s => ({ ...s, from: { ...s.from }, to: { ...s.to } })), counters: { ...(m?.counters ?? { requestFailures: 0, pluginRetries: 0, failoverSwitches: 0, longContextSwitches: 0 }) }, truncated: !!m?.truncated || cooling.length > 16 });
|
|
70
|
+
}
|
|
71
|
+
return { schemaVersion: 1, leadSessionId, epoch, revision, generatedAt: safeTime(now()) ?? 0, coverage: { mode: 'runtime', since }, members: result, taskControl: taskControlSnapshot(leadSessionId) };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Kept outside session checkpoints. Bound to the exact live Agent identity.
|
|
76
|
+
const runtimeBindings = new WeakMap();
|
|
77
|
+
export function bindRuntime(agent, runtime, teams) { runtimeBindings.set(agent, { runtime, teams }); }
|
|
78
|
+
export function runtimeBinding(agent) { return runtimeBindings.get(agent); }
|