dsh-device-fleet 0.1.0-dev.4 → 0.1.0-dev.5
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/package.json +1 -1
- package/src/host/delegation.js +124 -0
- package/src/host/direct-call.js +13 -0
- package/src/host/index.js +1 -0
- package/src/host/tool-policy.js +2 -1
- package/src/host/tools.js +27 -0
- package/src/plugin/index.js +27 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-device-fleet",
|
|
3
|
-
"version": "0.1.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.5",
|
|
4
4
|
"description": "DeepSeek Harness device fleet: enroll devices, direct-call remote tools, shared Rooms with lazy-woken agents, and delegated subagents across devices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/plugin/index.js",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Cross-device delegation (ADR-0015): the caller asks the target device's
|
|
2
|
+
// agent to do a one-shot task and gets the final assistant output back.
|
|
3
|
+
//
|
|
4
|
+
// On the target device this runs through the official SubagentProvider
|
|
5
|
+
// contract with `delivery: 'caller'`, so the child agent's own model turn and
|
|
6
|
+
// tools run on that device, and the `result` promise resolves with the final
|
|
7
|
+
// content blocks. No Room, no shared history: delegation is a one-shot task.
|
|
8
|
+
const DEFAULT_DELEGATE_TIMEOUT_MS = 180_000
|
|
9
|
+
const DEFAULT_PROVIDER = 'spawn'
|
|
10
|
+
const DEFAULT_LABEL = 'fleet delegation'
|
|
11
|
+
|
|
12
|
+
function textOf(result) {
|
|
13
|
+
const blocks = Array.isArray(result?.output) ? result.output : []
|
|
14
|
+
return blocks
|
|
15
|
+
.map((block) => (block?.type === 'text' ? block.text : `[${String(block?.type ?? 'block')}]`))
|
|
16
|
+
.join('\n')
|
|
17
|
+
.trim()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function withTimeout(promise, timeoutMs, onTimeout) {
|
|
21
|
+
let timer
|
|
22
|
+
return Promise.race([
|
|
23
|
+
promise.finally(() => clearTimeout(timer)),
|
|
24
|
+
new Promise((_, reject) => {
|
|
25
|
+
timer = setTimeout(() => reject(onTimeout()), timeoutMs)
|
|
26
|
+
timer.unref?.()
|
|
27
|
+
}),
|
|
28
|
+
])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class DelegationHost {
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} options
|
|
34
|
+
* @param {object} options.subagents ctx.subagents service
|
|
35
|
+
* @param {object} options.slot ExecutionSlot
|
|
36
|
+
* @param {import('./tool-policy.js').ToolPolicy} options.policy
|
|
37
|
+
* @param {(request: object) => Promise<boolean>} [options.requestApproval]
|
|
38
|
+
* @param {string} [options.deviceId]
|
|
39
|
+
* @param {string} [options.defaultCwd]
|
|
40
|
+
* @param {string} [options.defaultAgentPreset]
|
|
41
|
+
* @param {string} [options.provider]
|
|
42
|
+
* @param {number} [options.timeoutMs]
|
|
43
|
+
*/
|
|
44
|
+
constructor({
|
|
45
|
+
subagents,
|
|
46
|
+
slot,
|
|
47
|
+
policy,
|
|
48
|
+
requestApproval,
|
|
49
|
+
deviceId,
|
|
50
|
+
defaultCwd,
|
|
51
|
+
defaultAgentPreset,
|
|
52
|
+
provider = DEFAULT_PROVIDER,
|
|
53
|
+
timeoutMs = DEFAULT_DELEGATE_TIMEOUT_MS,
|
|
54
|
+
}) {
|
|
55
|
+
this.subagents = subagents
|
|
56
|
+
this.slot = slot
|
|
57
|
+
this.policy = policy
|
|
58
|
+
this.requestApproval = requestApproval
|
|
59
|
+
this.deviceId = deviceId
|
|
60
|
+
this.defaultCwd = defaultCwd
|
|
61
|
+
this.defaultAgentPreset = defaultAgentPreset
|
|
62
|
+
this.provider = provider
|
|
63
|
+
this.timeoutMs = timeoutMs
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async delegate({ fromDeviceId, request = {} }) {
|
|
67
|
+
const task = typeof request.task === 'string' ? request.task.trim() : ''
|
|
68
|
+
if (task === '') throw Object.assign(new Error('delegate needs a non-empty task'), { code: 'bad-request' })
|
|
69
|
+
|
|
70
|
+
const decision = this.policy.decide({ capability: 'delegate', args: {} })
|
|
71
|
+
if (decision.decision === 'deny') {
|
|
72
|
+
throw Object.assign(new Error(decision.reason), { code: 'denied', data: { capability: 'delegate' } })
|
|
73
|
+
}
|
|
74
|
+
if (decision.decision === 'ask') {
|
|
75
|
+
const approved =
|
|
76
|
+
typeof this.requestApproval === 'function'
|
|
77
|
+
? await this.requestApproval({ fromDeviceId, capability: 'delegate', tool: 'delegate', args: { task }, reason: decision.reason })
|
|
78
|
+
: false
|
|
79
|
+
if (approved !== true) throw Object.assign(new Error('local approval was not granted'), { code: 'approval_denied' })
|
|
80
|
+
this.policy.grant(decision.key, { scope: 'once' })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const agent = await this.slot.ensureAgent()
|
|
84
|
+
const controller = new AbortController()
|
|
85
|
+
const startedAt = Date.now()
|
|
86
|
+
let activation
|
|
87
|
+
try {
|
|
88
|
+
activation = await this.subagents.startActivation({
|
|
89
|
+
provider: request.provider ?? this.provider,
|
|
90
|
+
label: typeof request.label === 'string' && request.label !== '' ? request.label : DEFAULT_LABEL,
|
|
91
|
+
request: {
|
|
92
|
+
prompt: [{ type: 'text', text: task }],
|
|
93
|
+
parent: agent,
|
|
94
|
+
...(request.cwd ?? this.defaultCwd ? { cwd: request.cwd ?? this.defaultCwd } : {}),
|
|
95
|
+
...(request.agentPreset ?? this.defaultAgentPreset
|
|
96
|
+
? { agentPreset: request.agentPreset ?? this.defaultAgentPreset }
|
|
97
|
+
: {}),
|
|
98
|
+
},
|
|
99
|
+
signal: controller.signal,
|
|
100
|
+
delivery: 'caller',
|
|
101
|
+
})
|
|
102
|
+
const result = await withTimeout(
|
|
103
|
+
activation.result,
|
|
104
|
+
Number.isFinite(request.timeoutMs) ? request.timeoutMs : this.timeoutMs,
|
|
105
|
+
() => {
|
|
106
|
+
controller.abort()
|
|
107
|
+
return Object.assign(new Error('delegation timed out; the child agent was asked to stop'), { code: 'delegate-timeout' })
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
return {
|
|
111
|
+
text: textOf(result) || '(delegated agent returned no text)',
|
|
112
|
+
stopReason: result?.stopReason ?? null,
|
|
113
|
+
childId: activation.childId,
|
|
114
|
+
durationMs: Date.now() - startedAt,
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
try {
|
|
118
|
+
await activation?.dispose?.()
|
|
119
|
+
} catch {
|
|
120
|
+
/* best effort */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/host/direct-call.js
CHANGED
|
@@ -27,6 +27,7 @@ export class DirectCallHost {
|
|
|
27
27
|
* @param {string} [options.dshVersion]
|
|
28
28
|
* @param {string[]} [options.capabilities]
|
|
29
29
|
* @param {string} [options.shellTool]
|
|
30
|
+
* @param {object} [options.delegation] DelegationHost for `fleet.delegate`
|
|
30
31
|
*/
|
|
31
32
|
constructor({
|
|
32
33
|
slot,
|
|
@@ -40,6 +41,7 @@ export class DirectCallHost {
|
|
|
40
41
|
capabilities = [],
|
|
41
42
|
shellTool = DEFAULT_SHELL_TOOL,
|
|
42
43
|
approvalTimeoutMs = DEFAULT_POLICY_TIMEOUT_MS,
|
|
44
|
+
delegation = null,
|
|
43
45
|
}) {
|
|
44
46
|
this.slot = slot
|
|
45
47
|
this.policy = policy
|
|
@@ -52,6 +54,7 @@ export class DirectCallHost {
|
|
|
52
54
|
this.capabilities = capabilities
|
|
53
55
|
this.shellTool = shellTool
|
|
54
56
|
this.approvalTimeoutMs = approvalTimeoutMs
|
|
57
|
+
this.delegation = delegation
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
info() {
|
|
@@ -75,6 +78,11 @@ export class DirectCallHost {
|
|
|
75
78
|
return { deviceId: this.deviceId, tools: await this.slot.listTools(), policy: this.policy.summarize() }
|
|
76
79
|
case 'fleet.call':
|
|
77
80
|
return this.handleCall({ fromDeviceId: deviceId, request: params ?? {} })
|
|
81
|
+
case 'fleet.delegate':
|
|
82
|
+
if (!this.delegation) {
|
|
83
|
+
throw Object.assign(new Error('delegation is unavailable on this device'), { code: 'unsupported' })
|
|
84
|
+
}
|
|
85
|
+
return this.delegation.delegate({ fromDeviceId: deviceId, request: params ?? {} })
|
|
78
86
|
default:
|
|
79
87
|
throw Object.assign(new Error(`unknown fleet method: ${String(method)}`), { code: 'unknown-method' })
|
|
80
88
|
}
|
|
@@ -178,6 +186,11 @@ export class DirectCallClient {
|
|
|
178
186
|
return this.node.request(deviceId, 'fleet.call', { capability, tool, args }, options)
|
|
179
187
|
}
|
|
180
188
|
|
|
189
|
+
/** Ask the target device's agent to do a one-shot task and return its final text. */
|
|
190
|
+
async delegate(deviceId, request, options = {}) {
|
|
191
|
+
return this.node.request(deviceId, 'fleet.delegate', request, options)
|
|
192
|
+
}
|
|
193
|
+
|
|
181
194
|
/** Format a remote result for a model-facing text tool. */
|
|
182
195
|
render(result) {
|
|
183
196
|
if (result?.isError) return `Error: ${result.text || 'remote tool failed'}`
|
package/src/host/index.js
CHANGED
package/src/host/tool-policy.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the raw-tool allowlist, and short-lived grants created by approvals.
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
|
|
6
|
-
export const CAPABILITIES = ['read', 'list', 'grep', 'fetch', 'run', 'tool']
|
|
6
|
+
export const CAPABILITIES = ['read', 'list', 'grep', 'fetch', 'run', 'tool', 'delegate']
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_POLICY = Object.freeze({
|
|
9
9
|
allowedRoots: [],
|
|
@@ -14,6 +14,7 @@ export const DEFAULT_POLICY = Object.freeze({
|
|
|
14
14
|
fetch: 'auto',
|
|
15
15
|
run: 'ask',
|
|
16
16
|
tool: 'deny',
|
|
17
|
+
delegate: 'ask',
|
|
17
18
|
},
|
|
18
19
|
allowedTools: [],
|
|
19
20
|
approvalTtlMs: 15 * 60 * 1000,
|
package/src/host/tools.js
CHANGED
|
@@ -109,6 +109,20 @@ export function createFleetHandlers({ node, client = new DirectCallClient({ node
|
|
|
109
109
|
}
|
|
110
110
|
},
|
|
111
111
|
|
|
112
|
+
async fleet_delegate({ device, task, label, cwd }) {
|
|
113
|
+
try {
|
|
114
|
+
const result = await client.delegate(
|
|
115
|
+
device,
|
|
116
|
+
{ task, ...(label ? { label } : {}), ...(cwd ? { cwd } : {}) },
|
|
117
|
+
{ timeoutMs: 200_000 },
|
|
118
|
+
)
|
|
119
|
+
const meta = result?.childId ? `\n[delegated on ${device} · ${String(result.durationMs ?? 0)}ms · child ${String(result.childId)}]` : ''
|
|
120
|
+
return { text: `${String(result?.text ?? '')}${meta}`.trim() }
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return fail(error)
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
|
|
112
126
|
async fleet_read({ device, path, offset, limit }) {
|
|
113
127
|
try {
|
|
114
128
|
const args = { file_path: path }
|
|
@@ -211,6 +225,19 @@ export function createFleetToolDefinitions({ defineTool, handlers }) {
|
|
|
211
225
|
output,
|
|
212
226
|
execute: (args) => handlers.fleet_call(args),
|
|
213
227
|
}),
|
|
228
|
+
defineTool({
|
|
229
|
+
name: 'fleet_delegate',
|
|
230
|
+
description:
|
|
231
|
+
'Delegate a one-shot task to another device\'s agent and wait for its final answer. Use it when the work needs that device\'s model and tools ("let B build/check this"), not just one tool call. The delegated agent runs on the target device; it does not join a Room and its history is not shared. The target device decides whether delegation is allowed or needs local approval.',
|
|
232
|
+
parameters: {
|
|
233
|
+
device: commonDevice,
|
|
234
|
+
task: { type: 'string', required: true, description: 'Self-contained task instructions for the target agent.' },
|
|
235
|
+
label: { type: 'string', description: 'Short label shown in the target device\'s subagent list.' },
|
|
236
|
+
cwd: { type: 'string', description: 'Optional working directory on the target device.' },
|
|
237
|
+
},
|
|
238
|
+
output,
|
|
239
|
+
execute: (args) => handlers.fleet_delegate(args),
|
|
240
|
+
}),
|
|
214
241
|
defineTool({
|
|
215
242
|
name: 'fleet_read',
|
|
216
243
|
description:
|
package/src/plugin/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import Schema from '@deepseek-ai/schemastery'
|
|
|
13
13
|
import { FleetNode } from '../net/fleet-node.js'
|
|
14
14
|
import { accountFingerprint } from '../net/identity.js'
|
|
15
15
|
import { DirectCallClient, DirectCallHost } from '../host/direct-call.js'
|
|
16
|
+
import { DelegationHost } from '../host/delegation.js'
|
|
16
17
|
import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
|
|
17
18
|
import { ToolPolicy } from '../host/tool-policy.js'
|
|
18
19
|
import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
|
|
@@ -60,9 +61,11 @@ export const Config = Schema.object({
|
|
|
60
61
|
agentPreset: Schema.string(),
|
|
61
62
|
shellTool: Schema.string().default(DEFAULT_SHELL_TOOL),
|
|
62
63
|
approvalTimeoutMs: Schema.number().default(60000),
|
|
64
|
+
delegateTimeoutMs: Schema.number().default(180000),
|
|
65
|
+
subagentProvider: Schema.string().default('spawn'),
|
|
63
66
|
})
|
|
64
|
-
.default({ shellTool: DEFAULT_SHELL_TOOL, approvalTimeoutMs: 60000 })
|
|
65
|
-
.description('Execution Slot
|
|
67
|
+
.default({ shellTool: DEFAULT_SHELL_TOOL, approvalTimeoutMs: 60000, delegateTimeoutMs: 180000, subagentProvider: 'spawn' })
|
|
68
|
+
.description('Execution Slot and delegation settings for direct calls and one-shot delegation.'),
|
|
66
69
|
policy: Schema.object({
|
|
67
70
|
allowedRoots: Schema.array(Schema.string()).default([]),
|
|
68
71
|
capabilities: Schema.any().default({}),
|
|
@@ -153,11 +156,32 @@ export function apply(ctx, config) {
|
|
|
153
156
|
const policy = new ToolPolicy(config.policy)
|
|
154
157
|
const sessionController = service('sessionController')
|
|
155
158
|
const fs = service('fs')
|
|
159
|
+
const subagents = service('subagents')
|
|
156
160
|
if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
|
|
157
161
|
const slot = sessionController
|
|
158
162
|
? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
|
|
159
163
|
: null
|
|
160
164
|
|
|
165
|
+
const delegation =
|
|
166
|
+
slot && subagents
|
|
167
|
+
? new DelegationHost({
|
|
168
|
+
subagents,
|
|
169
|
+
slot,
|
|
170
|
+
policy,
|
|
171
|
+
deviceId: state.identity.deviceId,
|
|
172
|
+
defaultCwd: config.execution?.cwd,
|
|
173
|
+
defaultAgentPreset: config.execution?.agentPreset,
|
|
174
|
+
provider: config.execution?.subagentProvider ?? 'spawn',
|
|
175
|
+
timeoutMs: config.execution?.delegateTimeoutMs ?? 180_000,
|
|
176
|
+
requestApproval: config.autoApproveAsk
|
|
177
|
+
? async () => true
|
|
178
|
+
: async (request) => {
|
|
179
|
+
logger.info?.(`[device-fleet] delegation approval required from ${request.fromDeviceId} (no approval UI yet)`)
|
|
180
|
+
return false
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
: null
|
|
184
|
+
|
|
161
185
|
const host = slot
|
|
162
186
|
? new DirectCallHost({
|
|
163
187
|
slot,
|
|
@@ -170,6 +194,7 @@ export function apply(ctx, config) {
|
|
|
170
194
|
capabilities: CAPABILITIES,
|
|
171
195
|
shellTool: config.execution?.shellTool ?? DEFAULT_SHELL_TOOL,
|
|
172
196
|
approvalTimeoutMs: config.execution?.approvalTimeoutMs ?? 60_000,
|
|
197
|
+
delegation,
|
|
173
198
|
requestApproval: config.autoApproveAsk
|
|
174
199
|
? async () => true
|
|
175
200
|
: async (request) => {
|