@nanmicoder/dsh-agent-teams 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,7 +78,7 @@ Then ask for a team directly:
78
78
 
79
79
  Team state is stored under `<workspace>/.agent-teams/`; the Web panel reads that disk truth and combines it with live sub-agent activity.
80
80
 
81
- Member creation is zero-interaction by default: the plugin snapshots the LLM provider, model, and reasoning effort actually used by the captain's current step, and restores that snapshot on later continuations. Only an explicit heterogeneous-team request (for example, “backend on provider A/model X, frontend on provider B/model Y”) supplies a member-specific `provider` + `model`; there is no per-member model or reasoning prompt.
81
+ Member creation is zero-interaction by default: a member on the captain's current LLM route snapshots that provider, model, and reasoning effort, while a member on a requested alternative route snapshots the target model's default effort; later continuations restore the resolved snapshot. Only an explicit heterogeneous-team request (for example, “backend on provider A/model X, frontend on provider B/model Y”) supplies a member-specific `provider` + `model`; there is no per-member model or reasoning prompt.
82
82
 
83
83
  ## Configuration
84
84
 
@@ -94,7 +94,7 @@ Defaults work without extra setup. A trusted profile can override member behavio
94
94
  maxMembers: 8
95
95
  ```
96
96
 
97
- `memberProvider` is the sub-agent runtime backend (`spawn` / `fork`), not an LLM provider. Cross-LLM-provider routing uses the optional `provider` + `model` fields of `agent_teams_add_member`; `memberModel` is only a model default for all members.
97
+ `memberProvider` is the sub-agent runtime backend (`spawn` / `fork`), not an LLM provider. Cross-LLM-provider routing uses the optional `provider` + `model` fields of `agent_teams_add_member`; `memberModel` is only a model default for all members. A member on the captain's current provider/model inherits the captain's reasoning effort, while a changed provider or model automatically uses the target model's default. To request a particular effort, pass the optional `reasoning_effort` field — one of the target model's supported effort ids, or `"default"` to force the model's own default.
98
98
 
99
99
  ## Boundaries
100
100
 
package/README_ZH.md CHANGED
@@ -78,7 +78,7 @@ dsh web
78
78
 
79
79
  团队状态保存在 `<workspace>/.agent-teams/`;Web 面板读取这份磁盘真相,并与实时子 Agent 活动合并展示。
80
80
 
81
- 成员创建默认零交互:插件会快照队长**当前这一步**实际使用的 LLM provider、model 与思考强度,成员后续续跑仍使用这份快照。只有当用户明确提出异构分工(例如“后端用 provider A/model X,前端用 provider B/model Y”)时,队长才会把对应的 `provider` + `model` 传给该成员;不会逐个弹出模型或思考强度选择。
81
+ 成员创建默认零交互:成员沿用队长当前 LLM 路由时会快照该 provider、model 与思考强度;用户要求改用其他路由时,则快照目标模型的默认强度,成员后续续跑仍使用最终解析出的快照。只有当用户明确提出异构分工(例如“后端用 provider A/model X,前端用 provider B/model Y”)时,队长才会把对应的 `provider` + `model` 传给该成员;不会逐个弹出模型或思考强度选择。
82
82
 
83
83
  ## 配置
84
84
 
@@ -94,7 +94,7 @@ dsh web
94
94
  maxMembers: 8
95
95
  ```
96
96
 
97
- 这里的 `memberProvider` 指子 Agent 的运行后端(`spawn` / `fork`),不是 LLM provider。跨 LLM provider 由 `agent_teams_add_member` 的可选 `provider` + `model` 参数表达;`memberModel` 只是所有成员的模型默认覆盖。
97
+ 这里的 `memberProvider` 指子 Agent 的运行后端(`spawn` / `fork`),不是 LLM provider。跨 LLM provider 由 `agent_teams_add_member` 的可选 `provider` + `model` 参数表达;`memberModel` 只是所有成员的模型默认覆盖。成员沿用队长当前 provider/model 时会继承队长的思考强度;provider 或 model 任一改变时会自动使用目标模型的默认档。需要指定特定强度时,可传入可选的 `reasoning_effort` 参数(目标模型支持的档位 id,或 `"default"` 表示强制使用模型自身默认档)。
98
98
 
99
99
  ## 使用边界
100
100
 
package/lib/index.js CHANGED
@@ -40,7 +40,7 @@ export const Config = z.object({
40
40
  function usageSectionText(toolNames) {
41
41
  return `When the user asks to run something with AgentTeams (e.g. "use AgentTeams to do X"), you are the captain of a multi-agent team. Follow this protocol:
42
42
  1. Call agent_teams_create with a team name and the goal as description. You become the captain and may lead one team at a time.
43
- 2. Call agent_teams_add_member once per role the goal needs (researcher, engineer, reviewer, ...). Members are durable subagents: they wait for your messages, then work a full turn. By default each member snapshots your current provider, model, and reasoning effort. Never ask the user to choose these per member; only pass provider/model when the user explicitly requests a different route for that role.
43
+ 2. Call agent_teams_add_member once per role the goal needs (researcher, engineer, reviewer, ...). Members are durable subagents: they wait for your messages, then work a full turn. By default a member on your current provider/model snapshots your current reasoning effort; a member routed to a different provider or model automatically uses that target model's default effort. Never ask the user to choose these per member; only pass provider/model when the user explicitly requests a different route for that role, and reasoning_effort only when the user explicitly requests a particular effort ("default" explicitly selects the target model's default).
44
44
  3. Break the goal into tasks with agent_teams_create_task and wire dependencies. Assign role-specific work when useful; unassigned ready work belongs to the shared pool. The scheduler automatically claims one ready task for each truly idle member and wakes it, including across later rounds.
45
45
  4. Lead by delegation: monitor with agent_teams_status, send guidance with agent_teams_send_message, and let idle teammates execute ready work. Do not duplicate a teammate's work merely because its turn is slow.
46
46
  5. If work is blocked, stale, or needs takeover, always call agent_teams_reassign_task first. Reassign to another idle member, or use assignee=captain before doing it yourself. Reassignment revokes the old attempt and waits for that member to quiesce, preventing late results from overwriting the new attempt.
package/lib/members.js CHANGED
@@ -64,15 +64,18 @@ function modelSelection(selection) {
64
64
  }
65
65
  /**
66
66
  * Resolve one member's complete model selection. Ordinary members snapshot the
67
- * captain's current request route and reasoning effort. An explicit member
68
- * provider/model or plugin-level model replaces only that route; the current
69
- * captain effort remains the inherited policy and is validated against the
70
- * target model before a child is created.
67
+ * captain's current request route and reasoning effort. When provider or model
68
+ * changes, effort is intentionally omitted so the target model materializes
69
+ * its own default instead of receiving an adapter-owned id from another route.
70
+ * An explicit effort overrides either policy; the sentinel "default" also
71
+ * selects the target model's default. The final effort is validated against
72
+ * the target model before a child is created.
71
73
  */
72
74
  export async function resolveMemberLlmSelection(ctx, captain, request, signal) {
73
75
  const explicitProvider = request.provider?.trim();
74
76
  const explicitModel = request.model?.trim();
75
77
  const defaultModel = request.defaultModel?.trim();
78
+ const explicitEffort = request.reasoningEffort?.trim();
76
79
  if (request.provider !== undefined && explicitProvider === '') {
77
80
  throw new Error('member LLM provider must not be empty');
78
81
  }
@@ -82,21 +85,38 @@ export async function resolveMemberLlmSelection(ctx, captain, request, signal) {
82
85
  if (request.defaultModel !== undefined && defaultModel === '') {
83
86
  throw new Error('configured memberModel must not be empty');
84
87
  }
88
+ if (request.reasoningEffort !== undefined && explicitEffort === '') {
89
+ throw new Error('member reasoning effort must not be empty');
90
+ }
85
91
  if (explicitProvider !== undefined && explicitModel === undefined) {
86
92
  throw new Error('an explicit member LLM provider requires an explicit member model');
87
93
  }
88
94
  const current = captain.session.requestHeader()?.config;
89
- const provider = explicitProvider ?? current?.provider ?? captain.options.provider;
90
- const model = explicitModel ?? defaultModel ?? current?.model ?? captain.options.model;
95
+ const currentProvider = current?.provider ?? captain.options.provider;
96
+ const currentModel = current?.model ?? captain.options.model;
97
+ const provider = explicitProvider ?? currentProvider;
98
+ const model = explicitModel ?? defaultModel ?? currentModel;
91
99
  if (provider === undefined || model === undefined) {
92
100
  throw new Error('cannot resolve the member LLM route from the current captain session');
93
101
  }
102
+ // Effort ids belong to one exact provider/model capability. Preserve the
103
+ // captain's effort only on the same route; a changed route must resolve its
104
+ // own default. Explicit effort still wins, while "default" forces that
105
+ // target-default behavior even when the route did not change.
106
+ const sameRoute = provider === currentProvider && model === currentModel;
107
+ const reasoningEffort = explicitEffort === undefined
108
+ ? sameRoute
109
+ ? current?.reasoningEffort
110
+ : undefined
111
+ : explicitEffort === 'default'
112
+ ? undefined
113
+ : ReasoningEffortId(explicitEffort);
94
114
  const resolved = await ctx.llm.resolveCallConfig({
95
115
  provider,
96
116
  model,
97
- ...current?.reasoningEffort === undefined
117
+ ...reasoningEffort === undefined
98
118
  ? {}
99
- : { reasoningEffort: current.reasoningEffort },
119
+ : { reasoningEffort },
100
120
  }, signal);
101
121
  return {
102
122
  provider: resolved.provider,
package/lib/state.js CHANGED
@@ -444,17 +444,87 @@ export async function acknowledgeMailbox(stateRoot, teamId, agentKey, messageIds
444
444
  function stripLeadingBom(value) {
445
445
  return value.charCodeAt(0) === 0xFEFF ? value.slice(1) : value;
446
446
  }
447
- /** Atomically replace one UTF-8 state file from a same-directory temp file. */
447
+ /** Rename attempts before falling back to a direct overwrite. */
448
+ const ATOMIC_RENAME_RETRIES = 3;
449
+ /** Pause between rename attempts, giving a briefly-locking owner time to finish. */
450
+ const ATOMIC_RENAME_RETRY_DELAY_MS = 50;
451
+ /**
452
+ * Rename error codes worth retrying before the direct-write fallback. On
453
+ * Windows, replacing an existing file whose target is momentarily held open
454
+ * without FILE_SHARE_DELETE surfaces as EPERM (or EACCES/EBUSY variants);
455
+ * EEXIST/ENOTEMPTY cover other "target busy" edge shapes.
456
+ */
457
+ const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST', 'ENOTEMPTY']);
458
+ function isRetryableRenameError(error) {
459
+ return error instanceof Error
460
+ && 'code' in error
461
+ && RETRYABLE_RENAME_CODES.has(error.code ?? '');
462
+ }
463
+ function sleep(ms) {
464
+ return new Promise((resolve) => setTimeout(resolve, ms));
465
+ }
466
+ /**
467
+ * Replace `file` with `content`, preferring an atomic same-directory rename of
468
+ * an already-written temp file.
469
+ *
470
+ * On Windows, `rename(tmp, file)` over an existing target throws EPERM while
471
+ * any other process keeps the target open without FILE_SHARE_DELETE (editors,
472
+ * indexers, antivirus scans, preview panes). By that point the payload has
473
+ * already been fully written to the temp file, so a direct overwrite of the
474
+ * target is a content-equivalent degraded path: retry the rename a few times
475
+ * (transient locks clear quickly), then write the target in place. Every path
476
+ * removes the temp file; when both the atomic rename and the direct write
477
+ * fail, the combined error surfaces as an {@link AggregateError}.
478
+ *
479
+ * @returns nothing once the file has been replaced by one of the two paths.
480
+ */
481
+ export async function replaceFileAtomicOrDirect(temporary, file, content, primitives, options = {}) {
482
+ const retries = options.retries ?? ATOMIC_RENAME_RETRIES;
483
+ const retryDelayMs = options.retryDelayMs ?? ATOMIC_RENAME_RETRY_DELAY_MS;
484
+ for (let attempt = 0;; attempt += 1) {
485
+ try {
486
+ await primitives.rename(temporary, file);
487
+ return;
488
+ }
489
+ catch (error) {
490
+ if (isRetryableRenameError(error) && attempt < retries) {
491
+ await sleep(retryDelayMs);
492
+ continue;
493
+ }
494
+ let fallbackError;
495
+ try {
496
+ await primitives.writeFile(file, content);
497
+ }
498
+ catch (writeError) {
499
+ fallbackError = writeError;
500
+ }
501
+ await primitives.remove(temporary).catch(() => undefined);
502
+ if (fallbackError !== undefined) {
503
+ throw new AggregateError([error, fallbackError], `failed to replace "${file}" atomically (${String(error)}) or by direct write (${String(fallbackError)})`);
504
+ }
505
+ return;
506
+ }
507
+ }
508
+ }
509
+ /**
510
+ * Atomically replace one UTF-8 state file from a same-directory temp file,
511
+ * degrading to a direct overwrite when the atomic rename cannot proceed
512
+ * (see {@link replaceFileAtomicOrDirect} for the Windows EPERM rationale).
513
+ */
448
514
  async function atomicWriteText(file, content) {
449
515
  const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
450
516
  try {
451
517
  await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
452
- await rename(temporary, file);
453
518
  }
454
519
  catch (error) {
455
520
  await rm(temporary, { force: true }).catch(() => undefined);
456
521
  throw error;
457
522
  }
523
+ await replaceFileAtomicOrDirect(temporary, file, content, {
524
+ rename: (from, to) => rename(from, to),
525
+ writeFile: (target, payload) => writeFile(target, payload, 'utf8'),
526
+ remove: (path) => rm(path, { force: true }),
527
+ });
458
528
  }
459
529
  /** Whether a parsed JSON value is a plain record. */
460
530
  function isRecord(value) {
@@ -566,6 +636,30 @@ function isTeamMessage(value) {
566
636
  export async function removeTeamDir(stateRoot, teamId) {
567
637
  await rm(join(stateRoot, teamId), { recursive: true, force: true });
568
638
  }
639
+ /**
640
+ * `rename` with the same transient retry policy as the state-file atomic
641
+ * write, for paths (like archiving a whole team directory) where there is no
642
+ * content-equivalent direct-write degradation on Windows. A short-lived
643
+ * delete-sharing lock on any file below the renamed path is retried a few
644
+ * times before the error propagates.
645
+ * @param from - source path.
646
+ * @param to - destination path.
647
+ */
648
+ async function renameWithRetry(from, to) {
649
+ for (let attempt = 0;; attempt += 1) {
650
+ try {
651
+ await rename(from, to);
652
+ return;
653
+ }
654
+ catch (error) {
655
+ if (isRetryableRenameError(error) && attempt < ATOMIC_RENAME_RETRIES) {
656
+ await sleep(ATOMIC_RENAME_RETRY_DELAY_MS);
657
+ continue;
658
+ }
659
+ throw error;
660
+ }
661
+ }
662
+ }
569
663
  /**
570
664
  * Archive a team instead of deleting it: the whole directory (team.json with
571
665
  * tasks and dependency graph, plus the mailboxes) moves under
@@ -583,21 +677,26 @@ export async function archiveTeamDir(stateRoot, teamId) {
583
677
  const previous = join(archiveRoot, `.${teamId}.previous-${randomUUID()}`);
584
678
  let displaced = false;
585
679
  try {
586
- await rename(target, previous);
680
+ // The same Windows EPERM-on-rename applies at the directory boundary: a
681
+ // delete-sharing violation on any file below `target` blocks the move, so
682
+ // retry the transient-lock case before giving up.
683
+ await renameWithRetry(target, previous);
587
684
  displaced = true;
588
685
  }
589
686
  catch (error) {
687
+ // Only ENOENT means there was nothing to displace; any other failure
688
+ // (including a persistent EPERM lock) surfaces to the caller.
590
689
  if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
591
690
  throw error;
592
691
  }
593
692
  }
594
693
  try {
595
- await rename(source, target);
694
+ await renameWithRetry(source, target);
596
695
  }
597
696
  catch (error) {
598
697
  if (displaced) {
599
698
  try {
600
- await rename(previous, target);
699
+ await renameWithRetry(previous, target);
601
700
  }
602
701
  catch (restoreError) {
603
702
  throw new AggregateError([error, restoreError], `failed to archive team "${teamId}" and restore its previous archive`);
package/lib/tools.js CHANGED
@@ -221,12 +221,13 @@ export function registerAgentTeamsTools(ctx, config) {
221
221
  }));
222
222
  ctx.tools.register(defineTool({
223
223
  name: 'agent_teams_add_member',
224
- description: 'Add a durable continuable member. By default it snapshots the captain\'s current LLM provider, model, and reasoning effort with no user prompt. Supply provider/model only for an explicitly requested role-specific route. The member waits for messages, works on assigned tasks, and can message the team.',
224
+ description: 'Add a durable continuable member. By default it snapshots the captain\'s current LLM route and effort. Supply provider/model only for an explicitly requested role-specific route; a changed provider or model automatically uses the target model\'s default effort. Set reasoning_effort only to request one of the target model\'s supported ids explicitly (or "default" to force its default). The member waits for messages, works on assigned tasks, and can message the team.',
225
225
  parameters: {
226
226
  name: { type: 'string', required: true, description: 'Unique member name inside the team.' },
227
227
  role: { type: 'string', description: 'Role of the member (e.g. researcher, engineer, reviewer).' },
228
228
  provider: { type: 'string', description: 'Optional LLM provider route. Use only when the user explicitly requests a different provider; requires model.' },
229
229
  model: { type: 'string', description: 'Optional model override. Omit for the captain\'s current model (or the configured memberModel default).' },
230
+ reasoning_effort: { type: 'string', description: 'Optional reasoning effort override: one of the target model\'s supported effort ids, or "default" to force its default. When omitted, the captain\'s effort is inherited only for the same provider/model; a changed route uses the target default.' },
230
231
  },
231
232
  output: {
232
233
  schema: {
@@ -270,6 +271,7 @@ export function registerAgentTeamsTools(ctx, config) {
270
271
  provider: args.provider,
271
272
  model: args.model,
272
273
  defaultModel: config.memberModel,
274
+ reasoningEffort: args.reasoning_effort,
273
275
  }, exec.signal);
274
276
  const member = {
275
277
  id: '',
@@ -283,7 +285,19 @@ export function registerAgentTeamsTools(ctx, config) {
283
285
  };
284
286
  await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, fresh, member, config.stateDir, exec.signal);
285
287
  fresh.members.push(member);
286
- await writeTeam(stateRoot, fresh);
288
+ try {
289
+ await writeTeam(stateRoot, fresh);
290
+ }
291
+ catch (error) {
292
+ // The continuable child is already live, but the durable team record
293
+ // never saw it. Retire the orphan so it disappears from subagent
294
+ // listings and cannot be resumed, then surface the write failure.
295
+ if (member.id !== '') {
296
+ await recordRetiredMemberIds(stateRoot, [member.id]).catch(() => undefined);
297
+ interruptMember(ctx, captain, member.id);
298
+ }
299
+ throw error;
300
+ }
287
301
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/member-added', {
288
302
  teamId: fresh.id,
289
303
  memberId: member.id,
@@ -38,6 +38,8 @@ export interface MemberLlmSelectionRequest {
38
38
  model?: string;
39
39
  /** Plugin-level member model default. */
40
40
  defaultModel?: string;
41
+ /** Explicit reasoning effort; "default" selects the target model's default effort. */
42
+ reasoningEffort?: string;
41
43
  }
42
44
  /** Process-local bridge between spawn admission and synchronous child setup. */
43
45
  export interface MemberSelectionRuntime {
@@ -46,10 +48,12 @@ export interface MemberSelectionRuntime {
46
48
  }
47
49
  /**
48
50
  * Resolve one member's complete model selection. Ordinary members snapshot the
49
- * captain's current request route and reasoning effort. An explicit member
50
- * provider/model or plugin-level model replaces only that route; the current
51
- * captain effort remains the inherited policy and is validated against the
52
- * target model before a child is created.
51
+ * captain's current request route and reasoning effort. When provider or model
52
+ * changes, effort is intentionally omitted so the target model materializes
53
+ * its own default instead of receiving an adapter-owned id from another route.
54
+ * An explicit effort overrides either policy; the sentinel "default" also
55
+ * selects the target model's default. The final effort is validated against
56
+ * the target model before a child is created.
53
57
  */
54
58
  export declare function resolveMemberLlmSelection(ctx: Context, captain: Agent, request: MemberLlmSelectionRequest, signal?: AbortSignal): Promise<MemberLlmSelection>;
55
59
  /**
@@ -149,6 +149,35 @@ export declare function releaseMailboxDelivery(stateRoot: string, teamId: string
149
149
  * malformed lines for diagnostics. Callers serialize this with the team lock.
150
150
  */
151
151
  export declare function acknowledgeMailbox(stateRoot: string, teamId: string, agentKey: string, messageIds: readonly string[]): Promise<void>;
152
+ /** Filesystem primitives used by {@link replaceFileAtomicOrDirect}; injectable for tests. */
153
+ export interface AtomicReplacePrimitives {
154
+ rename: (from: string, to: string) => Promise<void>;
155
+ writeFile: (file: string, content: string) => Promise<void>;
156
+ remove: (file: string) => Promise<void>;
157
+ }
158
+ /** Tuning knobs for {@link replaceFileAtomicOrDirect} (defaults match production). */
159
+ export interface AtomicReplaceOptions {
160
+ /** Rename attempts before the direct-write fallback (default 3). */
161
+ retries?: number;
162
+ /** Delay between rename attempts in ms (default 50). */
163
+ retryDelayMs?: number;
164
+ }
165
+ /**
166
+ * Replace `file` with `content`, preferring an atomic same-directory rename of
167
+ * an already-written temp file.
168
+ *
169
+ * On Windows, `rename(tmp, file)` over an existing target throws EPERM while
170
+ * any other process keeps the target open without FILE_SHARE_DELETE (editors,
171
+ * indexers, antivirus scans, preview panes). By that point the payload has
172
+ * already been fully written to the temp file, so a direct overwrite of the
173
+ * target is a content-equivalent degraded path: retry the rename a few times
174
+ * (transient locks clear quickly), then write the target in place. Every path
175
+ * removes the temp file; when both the atomic rename and the direct write
176
+ * fail, the combined error surfaces as an {@link AggregateError}.
177
+ *
178
+ * @returns nothing once the file has been replaced by one of the two paths.
179
+ */
180
+ export declare function replaceFileAtomicOrDirect(temporary: string, file: string, content: string, primitives: AtomicReplacePrimitives, options?: AtomicReplaceOptions): Promise<void>;
152
181
  /**
153
182
  * Remove a team's whole directory (members should be interrupted first).
154
183
  * @param stateRoot - resolved absolute state root directory.
@@ -51,7 +51,7 @@ export interface TeamMember {
51
51
  provider?: string;
52
52
  /** Resolved model captured when this member was created. */
53
53
  model?: string;
54
- /** Resolved reasoning effort captured from the captain's current session. */
54
+ /** Resolved reasoning effort captured from the captain or target model default. */
55
55
  reasoningEffort?: string;
56
56
  joinedAt: number;
57
57
  status: MemberStatus;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanmicoder/dsh-agent-teams",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "AgentTeams for DeepSeek Harness: multi-agent team collaboration (captain, members, tasks with dependencies, messaging) driven by natural language, with a tree monitor in the web GUI",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",