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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-device-fleet",
3
- "version": "0.1.0-dev.4",
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
+ }
@@ -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
@@ -3,4 +3,5 @@ export * from './store.js'
3
3
  export * from './tool-policy.js'
4
4
  export * from './execution-slot.js'
5
5
  export * from './direct-call.js'
6
+ export * from './delegation.js'
6
7
  export * from './tools.js'
@@ -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:
@@ -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: the hidden DSH session used to execute remote tool calls.'),
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) => {