@sqdr2/dsh-fusion 0.0.0-stage → 0.3.2
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/INSTALL.md +89 -0
- package/INSTALL.zh.md +86 -0
- package/LICENSE +201 -0
- package/README.en.md +193 -0
- package/README.md +182 -2
- package/RELEASING.md +123 -0
- package/RELEASING.zh.md +115 -0
- package/contracts/fusion.ts +279 -0
- package/contracts/storage.sql +86 -0
- package/cordis.patch.yml +3 -0
- package/docs/COMPATIBILITY.md +162 -0
- package/docs/EVIDENCE.md +141 -0
- package/dshx.yml +39 -0
- package/lib/client.js +1920 -0
- package/lib/client.js.map +1 -0
- package/lib/dsh-fusion.js +12664 -0
- package/lib/types/activity.d.ts +20 -0
- package/lib/types/approval/logical.d.ts +10 -0
- package/lib/types/benchmark/naive.d.ts +37 -0
- package/lib/types/benchmark/native-run.d.ts +68 -0
- package/lib/types/benchmark/output-limits.d.ts +19 -0
- package/lib/types/bilingual.d.ts +2 -0
- package/lib/types/client/ActivityRows.d.ts +8 -0
- package/lib/types/client/FusionCache.d.ts +4 -0
- package/lib/types/client/FusionHint.d.ts +9 -0
- package/lib/types/client/FusionHistory.d.ts +5 -0
- package/lib/types/client/FusionPanelIcon.d.ts +13 -0
- package/lib/types/client/FusionRecovery.d.ts +5 -0
- package/lib/types/client/FusionSettings.d.ts +9 -0
- package/lib/types/client/FusionStatus.d.ts +5 -0
- package/lib/types/client/FusionTool.d.ts +4 -0
- package/lib/types/client/activity-model.d.ts +14 -0
- package/lib/types/client/i18n.d.ts +15 -0
- package/lib/types/client/index.d.ts +22 -0
- package/lib/types/client/settings-events.d.ts +2 -0
- package/lib/types/client/status-polling.d.ts +9 -0
- package/lib/types/context/guard.d.ts +21 -0
- package/lib/types/context/handoff.d.ts +17 -0
- package/lib/types/contracts.d.ts +449 -0
- package/lib/types/digest.d.ts +5 -0
- package/lib/types/dsh-fusion.d.ts +13 -0
- package/lib/types/errors.d.ts +28 -0
- package/lib/types/evidence/gate.d.ts +23 -0
- package/lib/types/evidence/receipts.d.ts +65 -0
- package/lib/types/execution/write-lease.d.ts +42 -0
- package/lib/types/host/bindings.d.ts +30 -0
- package/lib/types/host/cache-defaults.d.ts +48 -0
- package/lib/types/host/cache-policy.d.ts +155 -0
- package/lib/types/host/change-evidence.d.ts +38 -0
- package/lib/types/host/compaction-routing.d.ts +15 -0
- package/lib/types/host/coordinator.d.ts +169 -0
- package/lib/types/host/evidence-page.d.ts +13 -0
- package/lib/types/host/history.d.ts +88 -0
- package/lib/types/host/http.d.ts +4 -0
- package/lib/types/host/model-output.d.ts +13 -0
- package/lib/types/host/native-activity.d.ts +26 -0
- package/lib/types/host/native-approvals.d.ts +29 -0
- package/lib/types/host/native-auxiliary.d.ts +17 -0
- package/lib/types/host/native-budget.d.ts +33 -0
- package/lib/types/host/native-checks.d.ts +104 -0
- package/lib/types/host/native-compaction.d.ts +8 -0
- package/lib/types/host/native-context.d.ts +36 -0
- package/lib/types/host/native-effects.d.ts +75 -0
- package/lib/types/host/native-evidence.d.ts +4 -0
- package/lib/types/host/native-exploration.d.ts +15 -0
- package/lib/types/host/native-keepalive.d.ts +82 -0
- package/lib/types/host/native-model-control.d.ts +99 -0
- package/lib/types/host/native-on-demand-context.d.ts +39 -0
- package/lib/types/host/native-request.d.ts +5 -0
- package/lib/types/host/native-review.d.ts +11 -0
- package/lib/types/host/native-role-sandbox.d.ts +49 -0
- package/lib/types/host/native-scopes.d.ts +49 -0
- package/lib/types/host/native-selection.d.ts +29 -0
- package/lib/types/host/native-task-context.d.ts +39 -0
- package/lib/types/host/native-usage.d.ts +46 -0
- package/lib/types/host/native-worker-brief.d.ts +74 -0
- package/lib/types/host/native-worker-notices.d.ts +15 -0
- package/lib/types/host/native-worker.d.ts +44 -0
- package/lib/types/host/native-workflow.d.ts +41 -0
- package/lib/types/host/route-transport.d.ts +22 -0
- package/lib/types/host/settings.d.ts +39 -0
- package/lib/types/host/shell.d.ts +11 -0
- package/lib/types/host/status.d.ts +7 -0
- package/lib/types/host/subagent-seam.d.ts +10 -0
- package/lib/types/host/tool-table.d.ts +35 -0
- package/lib/types/host/worker-terminal.d.ts +68 -0
- package/lib/types/host/workspace-guard.d.ts +13 -0
- package/lib/types/host/workspace.d.ts +52 -0
- package/lib/types/identity.d.ts +64 -0
- package/lib/types/index.d.ts +34 -0
- package/lib/types/message-source.d.ts +29 -0
- package/lib/types/profile/compactor.d.ts +5 -0
- package/lib/types/profile/keepalive.d.ts +3 -0
- package/lib/types/profile/resolve.d.ts +17 -0
- package/lib/types/prompts.d.ts +8 -0
- package/lib/types/recovery/reconcile.d.ts +28 -0
- package/lib/types/review/dispatch.d.ts +20 -0
- package/lib/types/review/protocol.d.ts +51 -0
- package/lib/types/review/ticket.d.ts +32 -0
- package/lib/types/status.d.ts +88 -0
- package/lib/types/store-migration.d.ts +29 -0
- package/lib/types/task/deletion.d.ts +64 -0
- package/lib/types/task/reducer.d.ts +6 -0
- package/lib/types/task/sqlite-store.d.ts +71 -0
- package/lib/types/task/state.d.ts +51 -0
- package/lib/types/task/status-index.d.ts +28 -0
- package/lib/types/task/store.d.ts +48 -0
- package/lib/types/usage/budget.d.ts +27 -0
- package/lib/types/usage/ledger.d.ts +66 -0
- package/lib/types/usage/normalize.d.ts +50 -0
- package/lib/types/usage/pricing.d.ts +18 -0
- package/package.json +178 -4
- package/profiles/fusion-auto.example.json +45 -0
- package/prompts/compact.md +7 -0
- package/prompts/lead.md +42 -0
- package/prompts/model-lead.md +13 -0
- package/prompts/model-worker.md +5 -0
- package/prompts/worker.md +21 -0
- package/src/activity.ts +23 -0
- package/src/approval/logical.ts +44 -0
- package/src/benchmark/naive.ts +216 -0
- package/src/benchmark/native-run.ts +215 -0
- package/src/benchmark/output-limits.ts +33 -0
- package/src/bilingual.ts +2 -0
- package/src/client/ActivityRows.tsx +59 -0
- package/src/client/FusionCache.tsx +130 -0
- package/src/client/FusionHint.tsx +39 -0
- package/src/client/FusionHistory.tsx +247 -0
- package/src/client/FusionPanelIcon.tsx +39 -0
- package/src/client/FusionRecovery.tsx +69 -0
- package/src/client/FusionSettings.tsx +205 -0
- package/src/client/FusionStatus.tsx +103 -0
- package/src/client/FusionTool.tsx +66 -0
- package/src/client/activity-model.ts +31 -0
- package/src/client/i18n.ts +30 -0
- package/src/client/index.ts +50 -0
- package/src/client/settings-events.ts +3 -0
- package/src/client/status-polling.ts +88 -0
- package/src/context/guard.ts +81 -0
- package/src/context/handoff.ts +28 -0
- package/src/contracts.ts +449 -0
- package/src/digest.ts +28 -0
- package/src/dsh-fusion.ts +321 -0
- package/src/errors.ts +34 -0
- package/src/evidence/gate.ts +290 -0
- package/src/evidence/receipts.ts +175 -0
- package/src/execution/write-lease.ts +187 -0
- package/src/host/bindings.ts +91 -0
- package/src/host/cache-defaults.ts +103 -0
- package/src/host/cache-policy.ts +168 -0
- package/src/host/change-evidence.ts +156 -0
- package/src/host/compaction-routing.ts +46 -0
- package/src/host/coordinator.ts +2160 -0
- package/src/host/evidence-page.ts +25 -0
- package/src/host/history.ts +114 -0
- package/src/host/http.ts +30 -0
- package/src/host/model-output.ts +22 -0
- package/src/host/native-activity.ts +199 -0
- package/src/host/native-approvals.ts +196 -0
- package/src/host/native-auxiliary.ts +19 -0
- package/src/host/native-budget.ts +112 -0
- package/src/host/native-checks.ts +474 -0
- package/src/host/native-compaction.ts +34 -0
- package/src/host/native-context.ts +204 -0
- package/src/host/native-effects.ts +260 -0
- package/src/host/native-evidence.ts +31 -0
- package/src/host/native-exploration.ts +114 -0
- package/src/host/native-keepalive.ts +292 -0
- package/src/host/native-model-control.ts +298 -0
- package/src/host/native-on-demand-context.ts +74 -0
- package/src/host/native-request.ts +12 -0
- package/src/host/native-review.ts +51 -0
- package/src/host/native-role-sandbox.ts +171 -0
- package/src/host/native-scopes.ts +203 -0
- package/src/host/native-selection.ts +110 -0
- package/src/host/native-task-context.ts +264 -0
- package/src/host/native-usage.ts +121 -0
- package/src/host/native-worker-brief.ts +122 -0
- package/src/host/native-worker-notices.ts +72 -0
- package/src/host/native-worker.ts +215 -0
- package/src/host/native-workflow.ts +103 -0
- package/src/host/route-transport.ts +54 -0
- package/src/host/settings.ts +93 -0
- package/src/host/shell.ts +13 -0
- package/src/host/status.ts +112 -0
- package/src/host/subagent-seam.ts +20 -0
- package/src/host/tool-table.ts +56 -0
- package/src/host/worker-terminal.ts +91 -0
- package/src/host/workspace-guard.ts +17 -0
- package/src/host/workspace.ts +184 -0
- package/src/identity.ts +76 -0
- package/src/index.ts +50 -0
- package/src/message-source.ts +31 -0
- package/src/profile/compactor.ts +26 -0
- package/src/profile/keepalive.ts +13 -0
- package/src/profile/resolve.ts +131 -0
- package/src/prompts.ts +32 -0
- package/src/recovery/reconcile.ts +38 -0
- package/src/review/dispatch.ts +48 -0
- package/src/review/protocol.ts +212 -0
- package/src/review/ticket.ts +166 -0
- package/src/status.ts +55 -0
- package/src/store-migration.ts +65 -0
- package/src/task/deletion.ts +105 -0
- package/src/task/reducer.ts +679 -0
- package/src/task/sqlite-store.ts +386 -0
- package/src/task/state.ts +72 -0
- package/src/task/status-index.ts +198 -0
- package/src/task/store.ts +287 -0
- package/src/usage/budget.ts +88 -0
- package/src/usage/ledger.ts +218 -0
- package/src/usage/normalize.ts +308 -0
- package/src/usage/pricing.ts +95 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
3
|
+
import { createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
|
|
4
|
+
import type { GenerateOptions, UserMessage } from '@deepseek-ai/dsh-llm'
|
|
5
|
+
import type { ArtifactRef, Role, TaskId } from '../contracts.js'
|
|
6
|
+
import { digestOf } from '../digest.js'
|
|
7
|
+
import type { SqliteFusionStore } from '../task/sqlite-store.js'
|
|
8
|
+
import type { SessionBinding } from './bindings.js'
|
|
9
|
+
import { compactHandoffs, workerHandoffProjection } from './native-worker-brief.js'
|
|
10
|
+
import { FUSION_SOURCE_KIND, isFusionSourceKind } from '../message-source.js'
|
|
11
|
+
|
|
12
|
+
export const FUSION_TASK_CONTEXT = 'fusion:task'
|
|
13
|
+
export type ContextDetail = 'compact' | 'full'
|
|
14
|
+
|
|
15
|
+
type Facts = Record<string, unknown> & {
|
|
16
|
+
workOrder: { id: string; revision: number; mode: string; allowedPaths: readonly string[]; acceptance: readonly { id: string; description: string }[] } | null
|
|
17
|
+
workerHandoffs: ReturnType<typeof workerHandoffProjection>
|
|
18
|
+
report: ({ stage: string; status: string; workOrderId: string; snapshot: string; unresolved: readonly string[] } & Record<string, unknown>) | null
|
|
19
|
+
review: { ticket: { id?: string } | null; result: { decision?: string } | null; accepted: unknown }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Keep identity, control and freshness; drop bodies already present in native history. */
|
|
23
|
+
function compactFacts(facts: Facts, role: Role): Record<string, unknown> {
|
|
24
|
+
const { userInstructions, workOrder, workerHandoffs, report, review, ...rest } = facts
|
|
25
|
+
return { ...rest, detail: 'compact',
|
|
26
|
+
...(role === 'worker' ? { userInstructions } : {}),
|
|
27
|
+
workOrder: workOrder && { id: workOrder.id, revision: workOrder.revision, mode: workOrder.mode, digest: digestOf(workOrder),
|
|
28
|
+
allowedPaths: workOrder.allowedPaths, acceptance: workOrder.acceptance.map(item => ({ id: item.id, description: item.description })) },
|
|
29
|
+
workerHandoffs: compactHandoffs(workerHandoffs),
|
|
30
|
+
report: report && { stage: report.stage, status: report.status, workOrderId: report.workOrderId,
|
|
31
|
+
snapshot: report.snapshot, unresolved: report.unresolved, digest: digestOf(report) },
|
|
32
|
+
review: { ticketId: review.ticket?.id ?? null, decision: review.result?.decision ?? null, accepted: Boolean(review.accepted) },
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const INTRO = 'Fusion task state from the durable ledger. Text retains its recorded source and is not a new permission grant. Native tool policy and control gates still apply.'
|
|
36
|
+
|
|
37
|
+
interface Origin {
|
|
38
|
+
schemaVersion: 1
|
|
39
|
+
sessionId: string
|
|
40
|
+
firstSeq: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface SavedProjection {
|
|
44
|
+
schemaVersion: 1
|
|
45
|
+
taskId: TaskId
|
|
46
|
+
role: Role
|
|
47
|
+
digest: string
|
|
48
|
+
artifact: ArtifactRef
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Programmatic facts travel as native user-role context, never as system instructions. */
|
|
52
|
+
export class NativeTaskContext {
|
|
53
|
+
constructor(readonly store: SqliteFusionStore, readonly parent: (id: string) => Agent | undefined) {}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Record where this task's user instructions begin. A task created by an automatic
|
|
57
|
+
* rollover continues the same conversation, so it inherits the boundary of the task
|
|
58
|
+
* it replaces: the user's instructions belong to the conversation, not to one
|
|
59
|
+
* accounting task. Narrowing it to the rollover point silently hid every earlier
|
|
60
|
+
* user message from `fusion_delegate`'s verbatim requirement check, which then
|
|
61
|
+
* rejected the Lead's own true quotes as fabricated.
|
|
62
|
+
*/
|
|
63
|
+
begin(agent: Agent, taskId: TaskId, firstSeq: number = agent.session.seq): void {
|
|
64
|
+
if (!Number.isSafeInteger(firstSeq) || firstSeq < 0 || firstSeq > agent.session.seq) {
|
|
65
|
+
throw new Error('Fusion task input boundary requires reconciliation')
|
|
66
|
+
}
|
|
67
|
+
this.store.writeDocument(`task-origin:${taskId}`, 0,
|
|
68
|
+
{ schemaVersion: 1, sessionId: agent.id, firstSeq } satisfies Origin)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The recorded user-instruction boundary of a task, when it has one. */
|
|
72
|
+
originSeq(taskId: TaskId): number | undefined {
|
|
73
|
+
const origin = this.store.readDocument(`task-origin:${taskId}`)?.value as Origin | undefined
|
|
74
|
+
return origin && Number.isSafeInteger(origin.firstSeq) ? origin.firstSeq : undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `compact` is appended on every changed step: it carries only state the
|
|
79
|
+
* native history does not already hold, so the expensive Lead does not
|
|
80
|
+
* re-read its own brief, the delivered report or the user's messages on
|
|
81
|
+
* every request. `full` restores everything after compaction, when those
|
|
82
|
+
* native messages may have been summarized away.
|
|
83
|
+
*/
|
|
84
|
+
render(binding: SessionBinding, role: Role, detail: ContextDetail = 'compact'): string {
|
|
85
|
+
const state = this.store.load(binding.taskId), parent = this.parent(binding.sessionId)
|
|
86
|
+
if (!state || !parent || state.parent !== binding.sessionId || state.profileDigest !== binding.profile.digest) {
|
|
87
|
+
throw new Error('Fusion task context has no matching native parent or frozen profile')
|
|
88
|
+
}
|
|
89
|
+
const rawOrigin = this.store.readDocument(`task-origin:${binding.taskId}`)?.value as Origin | undefined
|
|
90
|
+
if (rawOrigin && (rawOrigin.schemaVersion !== 1 || rawOrigin.sessionId !== parent.id
|
|
91
|
+
|| !Number.isSafeInteger(rawOrigin.firstSeq) || rawOrigin.firstSeq < 0 || rawOrigin.firstSeq > parent.session.seq)) {
|
|
92
|
+
throw new Error('Fusion task input boundary requires reconciliation')
|
|
93
|
+
}
|
|
94
|
+
// Older bindings have no recorded boundary. Conservatively retain all
|
|
95
|
+
// original user text; do not guess which earlier instructions can be lost.
|
|
96
|
+
const firstSeq = rawOrigin?.firstSeq ?? 0
|
|
97
|
+
const instructions = parent.session.snapshotEvents().flatMap(event => {
|
|
98
|
+
if (event.seq < firstSeq || event.type !== 'user/message'
|
|
99
|
+
|| event.data.source !== undefined && event.data.source.kind !== 'user') return []
|
|
100
|
+
return [{ source: { sessionId: parent.id, eventSeq: event.seq, messageId: event.data.id,
|
|
101
|
+
kind: event.data.source?.kind ?? 'unspecified-native-user-role' },
|
|
102
|
+
text: event.data.content.filter(block => block.type === 'text').map(block => block.text),
|
|
103
|
+
// Binary/other inputs remain at their native source; do not relabel
|
|
104
|
+
// them as textual constraints or silently claim to have interpreted them.
|
|
105
|
+
otherContent: event.data.content.filter(block => block.type !== 'text').map(block => ({ type: block.type, digest: digestOf(block) })),
|
|
106
|
+
}]
|
|
107
|
+
})
|
|
108
|
+
const order = state.currentWorkOrder
|
|
109
|
+
const report = state.validatedReport ?? state.candidateReport
|
|
110
|
+
const refs = [...(order?.evidence ?? []), ...(state.exploration?.sources.map(source => source.excerpt) ?? []),
|
|
111
|
+
...(report ? [report.changeManifest, ...report.verification] : [])]
|
|
112
|
+
const artifacts = new Map(this.store.artifacts(binding.taskId).map(ref => [ref.id, ref]))
|
|
113
|
+
for (const ref of refs) {
|
|
114
|
+
const stored = artifacts.get(ref.id)
|
|
115
|
+
if (ref.ownerTaskId !== binding.taskId || !stored || stored.digest !== ref.digest || stored.bytes !== ref.bytes) {
|
|
116
|
+
throw new Error('Fusion task context references missing or foreign evidence')
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const facts = {
|
|
120
|
+
schemaVersion: 1, taskId: state.taskId, revision: state.revision, taskSeq: state.seq,
|
|
121
|
+
role, profileDigest: state.profileDigest,
|
|
122
|
+
inputScope: rawOrigin ? 'current-task-native-user-messages' : 'legacy-all-native-user-messages',
|
|
123
|
+
userInstructions: instructions,
|
|
124
|
+
phase: state.phase, intent: state.intent ?? null, control: state.control,
|
|
125
|
+
pendingApprovals: state.pendingApprovals, writer: state.lease ?? null,
|
|
126
|
+
workOrder: order && binding.profile.interactionMode === 'model-like' ? { ...order, policy: { mode: 'native', constraints: 'Native permissions, single writer, explicit check timeouts; no task request or rework cap' } } : order ?? null,
|
|
127
|
+
exploration: state.exploration ?? null,
|
|
128
|
+
workerHandoffs: workerHandoffProjection(this.store, binding.taskId),
|
|
129
|
+
nativeJobs: this.store.listDocumentIds(`native-effect:${binding.taskId}:`).flatMap(id => {
|
|
130
|
+
const effect = this.store.readDocument(id)!.value as { agentId?: string; state?: string; nativeJob?: unknown }
|
|
131
|
+
return effect.nativeJob ? [{ effectId: id, agentId: effect.agentId, state: effect.state, job: effect.nativeJob }] : []
|
|
132
|
+
}),
|
|
133
|
+
report: report ? { stage: state.validatedReport ? 'validated' : 'candidate', ...report } : null,
|
|
134
|
+
review: { ticket: state.activeReviewTicket ?? null, result: state.reviewResult ?? null,
|
|
135
|
+
accepted: state.acceptedReview ?? null },
|
|
136
|
+
verification: state.verification,
|
|
137
|
+
snapshot: state.lastSnapshot ?? report?.snapshot ?? order?.baseSnapshot ?? null,
|
|
138
|
+
}
|
|
139
|
+
const projected = detail === 'full' ? facts : compactFacts(facts as unknown as Facts, role)
|
|
140
|
+
const digest = digestOf(projected), id = this.#latest(binding, role)
|
|
141
|
+
const prior = this.store.readDocument(id)
|
|
142
|
+
const saved = prior?.value as SavedProjection | undefined
|
|
143
|
+
if (saved && (saved.schemaVersion !== 1 || saved.taskId !== binding.taskId || saved.role !== role)) {
|
|
144
|
+
throw new Error('Fusion task projection requires migration')
|
|
145
|
+
}
|
|
146
|
+
let artifact = saved?.digest === digest ? saved.artifact : undefined
|
|
147
|
+
if (artifact) this.store.readArtifact(binding.taskId, artifact.id)
|
|
148
|
+
else {
|
|
149
|
+
artifact = this.store.putArtifact(binding.taskId, Buffer.from(JSON.stringify(projected)), 'application/vnd.dsh-fusion.task-context+json')
|
|
150
|
+
this.store.writeDocument(id, prior?.revision ?? 0,
|
|
151
|
+
{ schemaVersion: 1, taskId: binding.taskId, role, digest, artifact } satisfies SavedProjection)
|
|
152
|
+
}
|
|
153
|
+
return this.#text(projected, digest, artifact)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Run after native pre-step handlers, including the existing compaction owner. */
|
|
157
|
+
message(agent: Agent, binding: SessionBinding, role: Role, detail?: ContextDetail): UserMessage | undefined {
|
|
158
|
+
const text = this.render(binding, role, detail ?? this.#detailFor(agent, binding, role))
|
|
159
|
+
const retained = agent.session.deriveMessages().some(message => isFusionSourceKind(message.source?.kind) && message.source.form === 'snapshot'
|
|
160
|
+
&& message.source.sections.some(section => section.name === FUSION_TASK_CONTEXT && section.text === text))
|
|
161
|
+
if (retained) return undefined
|
|
162
|
+
return this.#message(text)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The final frozen request must contain the snapshot that was actually assembled. */
|
|
166
|
+
assertPresent(agent: Agent, binding: SessionBinding, role: Role, request: GenerateOptions): void {
|
|
167
|
+
if (!isAgentLoopRequest(request) || request.purpose !== undefined) return
|
|
168
|
+
const saved = this.store.readDocument(this.#latest(binding, role))?.value as SavedProjection | undefined
|
|
169
|
+
if (!saved || saved.schemaVersion !== 1 || saved.taskId !== binding.taskId || saved.role !== role) {
|
|
170
|
+
throw new Error('Fusion task context was not assembled')
|
|
171
|
+
}
|
|
172
|
+
const facts = JSON.parse(Buffer.from(this.store.readArtifact(binding.taskId, saved.artifact.id)).toString('utf8'))
|
|
173
|
+
if (digestOf(facts) !== saved.digest) throw new Error('Fusion task projection checksum changed')
|
|
174
|
+
const expected = this.#text(facts, saved.digest, saved.artifact)
|
|
175
|
+
const present = request.messages.some(message => isFusionSourceKind(message.source?.kind)
|
|
176
|
+
&& message.source.form === 'snapshot'
|
|
177
|
+
&& message.source.sections.some(section => section.name === FUSION_TASK_CONTEXT && section.text === expected)
|
|
178
|
+
&& message.content.some(block => block.type === 'text' && block.text.includes(expected)))
|
|
179
|
+
if (!present) throw new Error(`Fusion task context is absent from the native request for ${agent.id}; execution is blocked`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
prepareCompaction(agent: Agent, binding: SessionBinding, role: Role, sourceSurfaceSeqs: readonly number[]): string {
|
|
183
|
+
this.render(binding, role, 'full')
|
|
184
|
+
const saved = this.store.readDocument(this.#latest(binding, role))!.value as SavedProjection
|
|
185
|
+
const state = this.store.load(binding.taskId)!
|
|
186
|
+
const id = `task-checkpoint:${binding.taskId}:${randomUUID()}`
|
|
187
|
+
this.store.writeDocument(id, 0, { schemaVersion: 1, state: 'prepared', taskId: binding.taskId,
|
|
188
|
+
agentId: agent.id, role, revision: state.revision, profileDigest: binding.profile.digest,
|
|
189
|
+
workOrderDigest: state.currentWorkOrder ? digestOf(state.currentWorkOrder) : null,
|
|
190
|
+
sourceSurfaceSeqs: [...sourceSurfaceSeqs], projection: saved })
|
|
191
|
+
return id
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
restoreCompaction(agent: Agent, binding: SessionBinding, role: Role, id: string, compactionId: string): void {
|
|
195
|
+
const row = this.store.readDocument(id)
|
|
196
|
+
const checkpoint = row?.value as { schemaVersion: number; state: string; taskId: TaskId; agentId: string;
|
|
197
|
+
role: Role; revision: number; profileDigest: string; workOrderDigest: string | null } | undefined
|
|
198
|
+
const state = this.store.load(binding.taskId)
|
|
199
|
+
if (!row || !checkpoint || !state || checkpoint.schemaVersion !== 1 || checkpoint.state !== 'prepared'
|
|
200
|
+
|| checkpoint.agentId !== agent.id || checkpoint.taskId !== binding.taskId || checkpoint.role !== role
|
|
201
|
+
|| checkpoint.revision !== state.revision || checkpoint.profileDigest !== binding.profile.digest
|
|
202
|
+
|| checkpoint.workOrderDigest !== (state.currentWorkOrder ? digestOf(state.currentWorkOrder) : null)) {
|
|
203
|
+
throw new Error('Fusion task changed during compaction; reconcile before retrying')
|
|
204
|
+
}
|
|
205
|
+
// Native request-error retries do not reassemble prompts. Restore facts as
|
|
206
|
+
// a real named native message before that retry, not by editing a request.
|
|
207
|
+
// The native compactor can retain the newest snapshot outside its replaced
|
|
208
|
+
// region. Appending that same 40 KB again would inflate the retry and could
|
|
209
|
+
// make a recoverable request fail the second compaction attempt.
|
|
210
|
+
const message = this.message(agent, binding, role, 'full')
|
|
211
|
+
const priorSnapshot = message && agent.session.surface.nodes.toReversed().find(seq => {
|
|
212
|
+
const event = agent.session.eventAt(seq)
|
|
213
|
+
if (event?.type !== 'user/message') return false
|
|
214
|
+
const { source, content } = event.data
|
|
215
|
+
// Only replace this plugin's single-section task snapshot. Other input,
|
|
216
|
+
// tools and summary events remain under their existing native owners.
|
|
217
|
+
if (!isFusionSourceKind(source?.kind) || source.form !== 'snapshot'
|
|
218
|
+
|| source.sections.length !== 1 || source.sections[0]!.name !== FUSION_TASK_CONTEXT
|
|
219
|
+
|| content.length !== 1 || content[0]!.type !== 'text' || content[0]!.text !== source.sections[0]!.text) return false
|
|
220
|
+
try {
|
|
221
|
+
const facts = JSON.parse(source.sections[0]!.text.slice(source.sections[0]!.text.indexOf('\n') + 1)).facts
|
|
222
|
+
return facts.taskId === binding.taskId && facts.role === role
|
|
223
|
+
} catch { return false }
|
|
224
|
+
})
|
|
225
|
+
const event = message && agent.session.append('user/message', message, priorSnapshot !== undefined
|
|
226
|
+
? { surfaceOp: { op: 'replace', startSeq: priorSnapshot, endSeq: priorSnapshot }, sourceEventSeqs: [priorSnapshot] }
|
|
227
|
+
: { surfaceOp: 'append' })
|
|
228
|
+
this.store.writeDocument(id, row.revision, { ...checkpoint, state: 'committed', compactionId,
|
|
229
|
+
restoredEventSeq: event?.seq ?? null, reusedExistingSnapshot: message === undefined,
|
|
230
|
+
replacedSnapshotSeq: priorSnapshot ?? null,
|
|
231
|
+
restoredProjection: this.store.readDocument(this.#latest(binding, role))!.value })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Compact snapshots rely on native history for briefs, reports and user text.
|
|
236
|
+
* Any compaction, including one the Host or the user started, may summarize
|
|
237
|
+
* those away, so the first snapshot after it (and a task's first) is full.
|
|
238
|
+
*/
|
|
239
|
+
#detailFor(agent: Agent, binding: SessionBinding, role: Role): ContextDetail {
|
|
240
|
+
let lastFull = -1, lastCompaction = -1
|
|
241
|
+
for (const event of agent.session.snapshotEvents()) {
|
|
242
|
+
if (event.type === 'compaction/end') lastCompaction = event.seq
|
|
243
|
+
if (event.type !== 'user/message' || !isFusionSourceKind(event.data.source?.kind)
|
|
244
|
+
|| event.data.source.form !== 'snapshot') continue
|
|
245
|
+
for (const section of event.data.source.sections) {
|
|
246
|
+
if (section.name !== FUSION_TASK_CONTEXT) continue
|
|
247
|
+
try {
|
|
248
|
+
const facts = JSON.parse(section.text.slice(section.text.indexOf('\n') + 1)).facts
|
|
249
|
+
if (facts.taskId === binding.taskId && facts.role === role && facts.detail !== 'compact') lastFull = event.seq
|
|
250
|
+
} catch { /* foreign or legacy text: not a full snapshot for this task */ }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return lastFull < 0 || lastCompaction > lastFull ? 'full' : 'compact'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#latest(binding: SessionBinding, role: Role): string { return `task-context:${binding.taskId}:${role}` }
|
|
257
|
+
#message(text: string): UserMessage {
|
|
258
|
+
return createUserMessage({ content: [{ type: 'text', text }],
|
|
259
|
+
source: { kind: FUSION_SOURCE_KIND, form: 'snapshot', sections: [{ name: FUSION_TASK_CONTEXT, text }] } })
|
|
260
|
+
}
|
|
261
|
+
#text(facts: unknown, digest: string, artifact: ArtifactRef): string {
|
|
262
|
+
return `${INTRO}\n${JSON.stringify({ facts, digest, sourceArtifact: artifact })}`
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { usageSummary } from './history.js'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
4
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
5
|
+
import type { StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
|
6
|
+
import type { Role, TaskId } from '../contracts.js'
|
|
7
|
+
import { digestOf } from '../digest.js'
|
|
8
|
+
import { ingestDurable, known, newLedger, unknown } from '../usage/ledger.js'
|
|
9
|
+
import type { CanonicalBill, UsageLedgerV2 } from '../usage/ledger.js'
|
|
10
|
+
import type { SqliteFusionStore } from '../task/sqlite-store.js'
|
|
11
|
+
import { nativeRequestAgent } from './native-request.js'
|
|
12
|
+
import type { NativeAuxiliaryRequests } from './native-auxiliary.js'
|
|
13
|
+
|
|
14
|
+
export interface UsageOwner { readonly taskId: TaskId; readonly role: Role }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* DSH counters are disjoint; missing provider fields retain unknown applicability.
|
|
18
|
+
*
|
|
19
|
+
* A counter is known only when the provider reported a real finite number.
|
|
20
|
+
* Gateways that omit completion_tokens_details send reasoningTokens as null
|
|
21
|
+
* (seen on some claude-opus-5.5 routes). known(null) throws INVALID_TOKEN_COUNT
|
|
22
|
+
* inside the llm/stream hook and aborts the turn, so null and NaN stay unknown.
|
|
23
|
+
*/
|
|
24
|
+
export function billFromNativeUsage(usage: TokenUsage | undefined): CanonicalBill {
|
|
25
|
+
const count = (value: number | undefined | null) =>
|
|
26
|
+
value === undefined || value === null || !Number.isFinite(value) ? unknown() : known(value)
|
|
27
|
+
const reasoningTokens = count(usage?.reasoningTokens)
|
|
28
|
+
return {
|
|
29
|
+
uncachedInput: count(usage?.inputTokens), cacheRead: count(usage?.cacheReadTokens), output: count(usage?.outputTokens),
|
|
30
|
+
cacheWrite: usage?.cacheWriteTokens === undefined || usage.cacheWriteTokens === null
|
|
31
|
+
|| !Number.isFinite(usage.cacheWriteTokens) ? { kind: 'unknown' }
|
|
32
|
+
: { kind: 'aggregate', rateKey: 'provider-cache-write-rate-unknown', tokens: known(usage.cacheWriteTokens) },
|
|
33
|
+
reasoning: reasoningTokens.state === 'known'
|
|
34
|
+
? { kind: 'separate', tokens: reasoningTokens }
|
|
35
|
+
: { kind: 'unknown', tokens: unknown() },
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NativeUsageRecord {
|
|
40
|
+
schemaVersion: 1
|
|
41
|
+
taskId: TaskId
|
|
42
|
+
sessionId: string
|
|
43
|
+
role: Role
|
|
44
|
+
purpose: 'conversation' | 'compaction' | 'session-title' | 'cache-keepalive'
|
|
45
|
+
/** Effective request envelope; Host compaction markers can retain their pre-routing cap. */
|
|
46
|
+
maxOutputTokens: number | null
|
|
47
|
+
reasoningEffort?: string
|
|
48
|
+
auxiliary?: { seriesId: string; iteration: number }
|
|
49
|
+
nativeInvocationId: string
|
|
50
|
+
startedAt: string
|
|
51
|
+
endedAt: string | null
|
|
52
|
+
/** Entering a Host stream is observable; the adapter's HTTP retry count is not. */
|
|
53
|
+
nativeStreamInvocations: 1
|
|
54
|
+
upstreamHttpCalls: null
|
|
55
|
+
outcome: 'entered' | 'stop' | 'tool-calls' | 'max-tokens' | 'aborted' | 'error' | 'unknown'
|
|
56
|
+
ledger: UsageLedgerV2
|
|
57
|
+
actualSubscriptionChargeUsd: null
|
|
58
|
+
apiEquivalentCostUsd: null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read-only stream observer. It neither changes requests nor orchestrates agents. */
|
|
62
|
+
export function observeNativeUsage(
|
|
63
|
+
ctx: Context, store: SqliteFusionStore, ownerOf: (agent: Agent) => UsageOwner | undefined,
|
|
64
|
+
auxiliary?: NativeAuxiliaryRequests,
|
|
65
|
+
): () => void {
|
|
66
|
+
return ctx.on('llm/stream', async function* (options, next): AsyncIterable<StreamChunk> {
|
|
67
|
+
const agent = nativeRequestAgent(ctx, options)
|
|
68
|
+
const owner = agent && ownerOf(agent)
|
|
69
|
+
if (!owner || !agent) { yield* next(); return }
|
|
70
|
+
const extra = auxiliary?.get(options)
|
|
71
|
+
const id = `native:${randomUUID()}`
|
|
72
|
+
const key = { provider: options.provider, model: options.model, requestId: id, attemptId: id,
|
|
73
|
+
contractDigest: digestOf({ host: '0.1.5-rc.2', input: 'disjoint', reasoning: 'unknown', missing: 'unknown' }) }
|
|
74
|
+
let ledger = newLedger(key)
|
|
75
|
+
let revision = 0, summaryRevision = 0
|
|
76
|
+
let sequence = 0
|
|
77
|
+
let lastUsage: TokenUsage | undefined
|
|
78
|
+
const record: NativeUsageRecord = {
|
|
79
|
+
schemaVersion: 1, taskId: owner.taskId, sessionId: agent.id, role: owner.role, purpose: extra?.purpose ?? options.purpose ?? 'conversation', nativeInvocationId: id,
|
|
80
|
+
maxOutputTokens: options.maxTokens ?? null,
|
|
81
|
+
...(options.reasoningEffort === undefined ? {} : { reasoningEffort: options.reasoningEffort }),
|
|
82
|
+
...(extra ? { auxiliary: { seriesId: extra.seriesId, iteration: extra.iteration } } : {}),
|
|
83
|
+
startedAt: new Date().toISOString(), endedAt: null, nativeStreamInvocations: 1, upstreamHttpCalls: null,
|
|
84
|
+
outcome: 'entered', ledger, actualSubscriptionChargeUsd: null, apiEquivalentCostUsd: null,
|
|
85
|
+
}
|
|
86
|
+
const persist = () => {
|
|
87
|
+
const value = { ...record, ledger }
|
|
88
|
+
;[revision, summaryRevision] = store.writeDocuments([
|
|
89
|
+
{ id: `usage:${owner.taskId}:${id}`, expectedRevision: revision, value },
|
|
90
|
+
{ id: `usage-index:${owner.taskId}:${id}`, expectedRevision: summaryRevision, value: usageSummary(value) },
|
|
91
|
+
]) as [number, number]
|
|
92
|
+
}
|
|
93
|
+
persist()
|
|
94
|
+
try {
|
|
95
|
+
for await (const chunk of next()) {
|
|
96
|
+
if (chunk.type === 'usage') {
|
|
97
|
+
lastUsage = chunk.usage
|
|
98
|
+
ledger = ingestDurable(ledger, { id: `${id}:${++sequence}`, key, sequence, mode: 'snapshot', bill: billFromNativeUsage(lastUsage) })
|
|
99
|
+
persist()
|
|
100
|
+
}
|
|
101
|
+
if (chunk.type === 'finish') {
|
|
102
|
+
const kind = chunk.reason.kind
|
|
103
|
+
record.outcome = ['stop', 'tool-calls', 'max-tokens', 'aborted', 'error'].includes(kind)
|
|
104
|
+
? kind as NativeUsageRecord['outcome'] : 'unknown'
|
|
105
|
+
record.endedAt = new Date().toISOString()
|
|
106
|
+
if (['stop', 'tool-calls', 'max-tokens'].includes(kind) && lastUsage) {
|
|
107
|
+
ledger = ingestDurable(ledger, { id: `${id}:final`, key, sequence: ++sequence, mode: 'final', bill: billFromNativeUsage(lastUsage) })
|
|
108
|
+
}
|
|
109
|
+
persist()
|
|
110
|
+
}
|
|
111
|
+
yield chunk
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
if (record.endedAt === null) {
|
|
115
|
+
record.outcome = options.signal?.aborted ? 'aborted' : 'unknown'
|
|
116
|
+
record.endedAt = new Date().toISOString()
|
|
117
|
+
persist()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
2
|
+
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
import { isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
|
|
4
|
+
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
|
5
|
+
import type { TaskId } from '../contracts.js'
|
|
6
|
+
import { digestOf } from '../digest.js'
|
|
7
|
+
import type { SqliteFusionStore } from '../task/sqlite-store.js'
|
|
8
|
+
import { recentHandoffSuffix } from '../context/handoff.js'
|
|
9
|
+
import { isFusionSourceKind } from '../message-source.js'
|
|
10
|
+
|
|
11
|
+
/** Durable Lead-authored refinements survive native compaction and request races. */
|
|
12
|
+
export function workerBriefs(store: SqliteFusionStore, taskId: TaskId) {
|
|
13
|
+
const revision = (store.readDocument(`runtime:${taskId}`)?.value as { briefRevision?: number } | undefined)?.briefRevision ?? 0
|
|
14
|
+
if (!Number.isSafeInteger(revision) || revision < 0) throw new Error('Worker brief revision requires reconciliation')
|
|
15
|
+
const rows = store.listDocumentIds(`worker-delivery:${taskId}:`).flatMap(id => {
|
|
16
|
+
const row = store.readDocument(id)!.value as { schemaVersion: number; taskId: string; workOrderId?: string; briefRevision: number; payloadRef: string; causeId: string; state: string }
|
|
17
|
+
if (row.workOrderId && row.workOrderId !== store.load(taskId)?.currentWorkOrder?.id) return []
|
|
18
|
+
if (row.state === 'not-applied-after-interruption') return []
|
|
19
|
+
if (row.briefRevision > revision) return [] // prepared before the runtime committed it
|
|
20
|
+
if (row.schemaVersion !== 1 || row.taskId !== taskId || !Number.isSafeInteger(row.briefRevision)
|
|
21
|
+
|| row.briefRevision < 1 || !row.payloadRef || !row.causeId) throw new Error('Worker brief journal requires reconciliation')
|
|
22
|
+
return [{ revision: row.briefRevision, deliveryId: id, source: 'lead-tool-feedback' as const, nativeCallId: row.causeId, payloadRef: row.payloadRef,
|
|
23
|
+
feedback: Buffer.from(store.readArtifact(taskId, row.payloadRef)).toString('utf8') }]
|
|
24
|
+
}).sort((left, right) => left.revision - right.revision)
|
|
25
|
+
if (rows.length !== revision || rows.some((row, index) => row.revision !== index + 1)) throw new Error('Worker brief history is missing or conflicting')
|
|
26
|
+
return rows
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pin a bounded projection, including the first handoff, on both native roles.
|
|
31
|
+
* The complete owned outbox, journal and artifact bytes remain durable. Original
|
|
32
|
+
* user input and the frozen work order are retained separately by NativeTaskContext.
|
|
33
|
+
*/
|
|
34
|
+
export function workerHandoffProjection(store: SqliteFusionStore, taskId: TaskId) {
|
|
35
|
+
const state = store.load(taskId)
|
|
36
|
+
if (!state) throw new Error('Worker handoff task is missing')
|
|
37
|
+
const records: { revision: number; source: 'lead-tool-handoff' | 'lead-tool-feedback';
|
|
38
|
+
sourceId: string; payloadRef: string; content: string }[] = []
|
|
39
|
+
const order = state.currentWorkOrder
|
|
40
|
+
if (order) {
|
|
41
|
+
const rows = store.outbox(taskId).filter(row => row.kind === 'native-worker' && row.operationId === order.operationId)
|
|
42
|
+
const row = rows[0], childId = state.acceptedChild ?? state.reservedChild
|
|
43
|
+
if (rows.length !== 1 || !row || row.taskId !== taskId || !childId || row.reservedChild !== childId || !row.payloadRef) {
|
|
44
|
+
throw new Error('Initial Worker handoff requires reconciliation')
|
|
45
|
+
}
|
|
46
|
+
records.push({ revision: 0, source: 'lead-tool-handoff', sourceId: row.operationId,
|
|
47
|
+
payloadRef: row.payloadRef, content: Buffer.from(store.readArtifact(taskId, row.payloadRef)).toString('utf8') })
|
|
48
|
+
}
|
|
49
|
+
const briefs = workerBriefs(store, taskId)
|
|
50
|
+
for (const brief of briefs) records.push({ revision: brief.revision, source: brief.source,
|
|
51
|
+
sourceId: brief.deliveryId, payloadRef: brief.payloadRef, content: brief.feedback })
|
|
52
|
+
return { schemaVersion: 1 as const, ...recentHandoffSuffix(records),
|
|
53
|
+
latestBriefRevision: briefs.at(-1)?.revision ?? 0, historyDigest: digestOf(records) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Brief bodies already arrive as native messages; the snapshot pins only their identity and digests. */
|
|
57
|
+
export function compactHandoffs(projection: ReturnType<typeof workerHandoffProjection>) {
|
|
58
|
+
const { records, ...rest } = projection
|
|
59
|
+
return { ...rest, records: records.map(({ content, ...record }) => ({ ...record, contentDigest: digestOf(content) })) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function assertWorkerBriefRequest(store: SqliteFusionStore, taskId: TaskId, request: GenerateOptions): void {
|
|
63
|
+
if (!isAgentLoopRequest(request) || request.purpose !== undefined) return
|
|
64
|
+
const expected = workerHandoffProjection(store, taskId)
|
|
65
|
+
if (!expected.totalRecords) return
|
|
66
|
+
const present = request.messages.some(message => isFusionSourceKind(message.source?.kind) && message.source.form === 'snapshot'
|
|
67
|
+
&& message.source.sections.some(section => {
|
|
68
|
+
if (section.name !== 'fusion:task') return false
|
|
69
|
+
try {
|
|
70
|
+
const seen = digestOf(JSON.parse(section.text.slice(section.text.indexOf('\n') + 1)).facts.workerHandoffs)
|
|
71
|
+
return seen === digestOf(expected) || seen === digestOf(compactHandoffs(expected))
|
|
72
|
+
} catch { return false }
|
|
73
|
+
}))
|
|
74
|
+
if (!present) throw new Error('Worker request predates the latest Lead brief; do not dispatch a stale generation')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Bind model-generated tools to the brief that their exact native request saw. */
|
|
78
|
+
export function captureWorkerBrief(store: SqliteFusionStore, taskId: TaskId, agent: Agent, turn: number, step: number, revision: number): void {
|
|
79
|
+
const id = `worker-request:${agent.id}:${turn}:${step}`
|
|
80
|
+
const value = { schemaVersion: 1, taskId, sessionId: agent.id, turn, step, briefRevision: revision,
|
|
81
|
+
workOrderId: store.load(taskId)?.currentWorkOrder?.id }
|
|
82
|
+
const prior = store.readDocument(id)
|
|
83
|
+
if (prior) {
|
|
84
|
+
if (digestOf(prior.value) !== digestOf(value)) throw new Error('Worker request was already bound to another brief')
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
store.writeDocument(id, 0, value)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Bind one non-read Worker tool to the brief its own request saw.
|
|
92
|
+
*
|
|
93
|
+
* A native call is identified by `rootCallId`, which is the id of the block the
|
|
94
|
+
* assistant message emitted. A bridge route (Antigravity's `agy`) instead runs
|
|
95
|
+
* the call inside its OWN loop and reports the step through its transport, so
|
|
96
|
+
* the bridged dispatch carries a bridge-local call id that never appears in this
|
|
97
|
+
* session (`agy-mcp-N`). Refusing it as "no native request" refused the one tool
|
|
98
|
+
* a bridge Worker exists to call — `fusion_submit_result` — and stranded the
|
|
99
|
+
* task (session-1d65b1d0). Such a call is still owned by a native request: it
|
|
100
|
+
* is dispatched synchronously while the transport call runs, so the enclosing
|
|
101
|
+
* assistant message is the one that emitted the transport. `transport` carries
|
|
102
|
+
* that name only for a role whose route really is a bridge.
|
|
103
|
+
*/
|
|
104
|
+
export function workerToolBriefProblem(store: SqliteFusionStore, taskId: TaskId, exec: ToolExecution, revision: number,
|
|
105
|
+
transport?: string): string | undefined {
|
|
106
|
+
const events = exec.agent?.session.snapshotEvents()
|
|
107
|
+
const native = events?.findLast(event => event.type === 'assistant/message'
|
|
108
|
+
&& event.data.message.content.some(block => block.type === 'tool-call' && block.id === exec.rootCallId))
|
|
109
|
+
const message = native ?? (transport === undefined ? undefined
|
|
110
|
+
: events?.findLast(event => event.type === 'assistant/message'
|
|
111
|
+
&& event.data.message.content.some(block => block.type === 'tool-call' && block.name === transport)))
|
|
112
|
+
if (!message || message.type !== 'assistant/message' || message.data.interrupted) return 'Worker tool requires a complete native model request'
|
|
113
|
+
const row = store.readDocument(`worker-request:${exec.agent!.id}:${message.data.turn}:${message.data.step}`)?.value as {
|
|
114
|
+
schemaVersion?: number; taskId?: string; sessionId?: string; turn?: number; step?: number; briefRevision?: number; workOrderId?: string
|
|
115
|
+
} | undefined
|
|
116
|
+
if (row?.schemaVersion !== 1 || row.taskId !== taskId || row.sessionId !== exec.agent!.id
|
|
117
|
+
|| row.turn !== message.data.turn || row.step !== message.data.step) return 'Worker tool has no captured brief identity'
|
|
118
|
+
if (row.briefRevision !== revision) return 'A newer Lead brief superseded this request; process it before further tools or a report'
|
|
119
|
+
const order = store.load(taskId)?.currentWorkOrder
|
|
120
|
+
if (row.workOrderId !== order?.id && (row.workOrderId || order?.mode)) return 'A newer work order superseded this request; process its plan before further tools'
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
2
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
3
|
+
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
|
4
|
+
import type {} from '@deepseek-ai/dsh-subagent'
|
|
5
|
+
import type { ArtifactRef, TaskId } from '../contracts.js'
|
|
6
|
+
import { digestOf } from '../digest.js'
|
|
7
|
+
import type { SqliteFusionStore } from '../task/sqlite-store.js'
|
|
8
|
+
import type { SessionBinding } from './bindings.js'
|
|
9
|
+
import { workerSettlementGuidance } from './worker-terminal.js'
|
|
10
|
+
import { FUSION_SOURCE_KIND } from '../message-source.js'
|
|
11
|
+
|
|
12
|
+
interface NoticeProjection {
|
|
13
|
+
schemaVersion: 1
|
|
14
|
+
taskId: TaskId
|
|
15
|
+
parentId: string
|
|
16
|
+
childId: string
|
|
17
|
+
originalMessageId: string
|
|
18
|
+
originalDigest: string
|
|
19
|
+
artifact: ArtifactRef
|
|
20
|
+
message: UserMessage
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Reduce only our Worker's runtime closing notice, never its structured report. */
|
|
24
|
+
export class NativeWorkerNotices {
|
|
25
|
+
constructor(readonly store: SqliteFusionStore) {}
|
|
26
|
+
|
|
27
|
+
project(agent: Agent, binding: SessionBinding, original: UserMessage): UserMessage {
|
|
28
|
+
const source = original.source
|
|
29
|
+
if (agent.id !== binding.sessionId || source?.kind !== 'subagent-settled') return original
|
|
30
|
+
const child = this.store.readDocument(`child:${source.senderSessionId}`)?.value as { parent?: string; taskId?: TaskId } | undefined
|
|
31
|
+
const task = child?.taskId && this.store.load(child.taskId)
|
|
32
|
+
if (child?.parent !== agent.id || !task || task.parent !== String(agent.id)
|
|
33
|
+
|| (task.acceptedChild !== String(source.senderSessionId) && task.reservedChild !== String(source.senderSessionId))) return original
|
|
34
|
+
|
|
35
|
+
const originalDigest = digestOf(original)
|
|
36
|
+
const id = `worker-notice:${binding.taskId}:${digestOf({ parent: agent.id, message: original.id })}`
|
|
37
|
+
const saved = this.store.readDocument(id)?.value as NoticeProjection | undefined
|
|
38
|
+
if (saved) {
|
|
39
|
+
if (saved.schemaVersion !== 1 || saved.taskId !== binding.taskId || saved.parentId !== agent.id
|
|
40
|
+
|| saved.childId !== source.senderSessionId || saved.originalDigest !== originalDigest) {
|
|
41
|
+
throw new Error('Fusion Worker notice identity requires reconciliation')
|
|
42
|
+
}
|
|
43
|
+
this.store.readArtifact(binding.taskId, saved.artifact.id)
|
|
44
|
+
return saved.message
|
|
45
|
+
}
|
|
46
|
+
// Keep the entire original, including failure detail and closing blocks,
|
|
47
|
+
// available through owned evidence. This projection claims no task success.
|
|
48
|
+
const artifact = this.store.putArtifact(binding.taskId, Buffer.from(JSON.stringify(original)), 'application/vnd.dsh-fusion.worker-notice+json')
|
|
49
|
+
const submitted = Boolean((this.store.readDocument(`runtime:${binding.taskId}`)?.value as { submitted?: unknown } | undefined)?.submitted)
|
|
50
|
+
const message = createUserMessage({ source: { kind: FUSION_SOURCE_KIND, form: 'notice', summary: source.summary },
|
|
51
|
+
content: [{ type: 'text', text: `${source.summary}\n${workerSettlementGuidance(submitted)} Closing transcript is archived rather than repeated here. For missing failure details use fusion_read_evidence with id ${artifact.id}.\n${JSON.stringify({ childId: source.senderSessionId, originalMessageId: original.id, artifact })}` }] })
|
|
52
|
+
this.store.writeDocument(id, 0, { schemaVersion: 1, taskId: binding.taskId, parentId: agent.id,
|
|
53
|
+
childId: source.senderSessionId, originalMessageId: original.id, originalDigest, artifact, message } satisfies NoticeProjection)
|
|
54
|
+
return message
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Replace retained legacy notices before the native compactor reads history.
|
|
58
|
+
* Public surface replacement preserves the original append-only event and
|
|
59
|
+
* cites it. Already compacted prose cannot be safely reverse-transformed.
|
|
60
|
+
*/
|
|
61
|
+
projectHistory(agent: Agent, binding: SessionBinding): void {
|
|
62
|
+
for (const seq of [...agent.session.surface.nodes]) {
|
|
63
|
+
const event = agent.session.eventAt(seq)
|
|
64
|
+
if (event?.type !== 'user/message') continue
|
|
65
|
+
const message = this.project(agent, binding, event.data)
|
|
66
|
+
if (message === event.data) continue
|
|
67
|
+
agent.session.append('user/message', message, {
|
|
68
|
+
surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq],
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|