@gpzhang2001/sharpkit-team 0.2.1

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/src/index.ts ADDED
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Red-team orchestration — the M4 tool-team layer: strix-named tools
3
+ * (create_agent / send_message_to_agent / wait_for_agents /
4
+ * view_agent_graph / stop_agent / agent_finish) composed over the dsh
5
+ * subagent SERVICE (ctx.subagents + the spawn provider's continuable
6
+ * children), not over tools. Children inherit the parent Agent's preset and
7
+ * full tool surface (in-process spawn semantics); requested skills are
8
+ * injected as a directive line in the child's prompt. S2-verified semantics
9
+ * hold: send steers at step boundaries, interrupt parks the inbox (children
10
+ * stay resumable), completion notices reach the parent's turn boundary.
11
+ * A token-budget circuit breaker interrupts every tracked child and blocks
12
+ * new spawns when the session's accumulated usage crosses the ceiling.
13
+ * @module @gpzhang2001/sharpkit-team
14
+ */
15
+
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import type Schema from '@deepseek-ai/schemastery'
18
+ import z from '@deepseek-ai/schemastery'
19
+ import { defineTool } from '@deepseek-ai/dsh-tools'
20
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
21
+ import type {} from '@deepseek-ai/dsh-subagent'
22
+ import type {} from '@deepseek-ai/dsh-session'
23
+
24
+ /** Structural view of the subagent service this package drives. */
25
+ export interface SubagentsLike {
26
+ start(name: string, request: {
27
+ readonly label?: string
28
+ readonly prompt: ContentBlock[]
29
+ readonly parent: unknown
30
+ readonly signal: AbortSignal
31
+ }): { readonly id: { readonly [key: string]: unknown } | string; readonly result: Promise<{ readonly stopReason?: string; readonly output?: readonly ContentBlock[] }> }
32
+ sendMessage(sender: unknown, targetId: unknown, content: ContentBlock[], options?: unknown): Promise<unknown>
33
+ interrupt(targetSessionId: unknown, authority:
34
+ | { readonly kind: 'user'; readonly parentSessionId: unknown }
35
+ | { readonly kind: 'ancestor'; readonly agent: unknown }): void
36
+ }
37
+
38
+ /** Deployment-tunable configuration. */
39
+ export interface Config {
40
+ /** Maximum delegation depth (root=1 spawns children; children don't spawn). */
41
+ readonly maxTeamDepth?: number
42
+ /** Explicit session token ceiling for the circuit breaker (overrides budget estimates). */
43
+ readonly maxSessionTokens?: number
44
+ /** USD budget ceiling for the circuit breaker (token-estimated; see usdPerMillionTokens). */
45
+ readonly maxBudgetUsd?: number
46
+ /** Estimated blended $/1M tokens mapping a USD budget to a token ceiling. */
47
+ readonly usdPerMillionTokens?: number
48
+ /** Skills catalog root — only used to validate requested skill names exist. */
49
+ readonly skillsRoot?: string
50
+ }
51
+
52
+ export const name = 'pentest-tool-team'
53
+
54
+ export const inject = ['tools']
55
+
56
+ export const Config: Schema<Config> = z.object({
57
+ maxTeamDepth: z.number().default(2),
58
+ maxSessionTokens: z.number(),
59
+ maxBudgetUsd: z.number(),
60
+ usdPerMillionTokens: z.number(),
61
+ skillsRoot: z.string(),
62
+ })
63
+
64
+ /** Default blended $/1M tokens when neither config nor preset supplies a mapping. */
65
+ const DEFAULT_USD_PER_MILLION_TOKENS = 0.5
66
+
67
+ /** Fallback token ceiling when no budget source is configured. */
68
+ const DEFAULT_MAX_SESSION_TOKENS = 2_000_000
69
+
70
+ /** One tracked child (roster row). */
71
+ interface TrackedChild {
72
+ readonly id: string
73
+ readonly label: string
74
+ readonly task: string
75
+ readonly skills: readonly string[]
76
+ readonly startedAt: string
77
+ status: 'running' | 'completed' | 'stopped' | 'failed'
78
+ completionReport: string | undefined
79
+ readonly result: Promise<{ readonly stopReason?: string; readonly output?: readonly ContentBlock[] }>
80
+ }
81
+
82
+ /** The orchestrator-facing team handle (tests + UI consume). */
83
+ export interface TeamHandle {
84
+ children(): ReadonlyArray<Readonly<TrackedChild>>
85
+ /** True once the circuit breaker tripped. */
86
+ isBreached(): boolean
87
+ tokensUsed(): number
88
+ }
89
+
90
+ /** Brand-free id extraction from the runtime's branded session ids. */
91
+ function idText(value: unknown): string {
92
+ return typeof value === 'string' ? value : String(value)
93
+ }
94
+
95
+ export function apply(ctx: Context, config: Config = {}): TeamHandle {
96
+ const children = new Map<string, TrackedChild>()
97
+ /** Sessions whose usage counts toward this team's budget (root + children). */
98
+ const teamSessions = new Set<string>()
99
+ /** The first caller's Agent — the ancestor authority for breaker interrupts. */
100
+ let rootAgent: unknown
101
+ let tokensUsed = 0
102
+ let breached = false
103
+ const maxTeamDepth = config.maxTeamDepth ?? 2
104
+ const usdPerMillionTokens = config.usdPerMillionTokens ?? DEFAULT_USD_PER_MILLION_TOKENS
105
+
106
+ const subagents = (): SubagentsLike => ctx.subagents as unknown as SubagentsLike
107
+
108
+ /** Budget ceiling in tokens: explicit tokens > config USD > preset USD > fallback. */
109
+ const tokenCeiling = (): number => {
110
+ if (config.maxSessionTokens !== undefined) return config.maxSessionTokens
111
+ const preset = ctx.get('pentestPreset') as { maxBudgetUsd?: number } | undefined
112
+ const budgetUsd = config.maxBudgetUsd ?? preset?.maxBudgetUsd
113
+ if (budgetUsd !== undefined) return Math.max(1, Math.floor((budgetUsd / usdPerMillionTokens) * 1_000_000))
114
+ return DEFAULT_MAX_SESSION_TOKENS
115
+ }
116
+
117
+ // Budget circuit breaker: accumulate this team's session usage and trip the
118
+ // breaker. The interrupt goes out under ancestor authority from the root
119
+ // agent (kind:'user' requires a human-presented parent session id, which a
120
+ // plugin-side breaker does not have).
121
+ void ctx.on('session/event', (session: { id?: unknown }, event: unknown) => {
122
+ const record = event as { type?: string; data?: { usage?: { inputTokens?: number; outputTokens?: number; input?: number; output?: number } } }
123
+ if (record.type !== 'assistant/message') return
124
+ const sessionId = session.id === undefined ? '' : String(session.id)
125
+ if (!teamSessions.has(sessionId)) return
126
+ const usage = record.data?.usage
127
+ if (usage === undefined || usage === null) return
128
+ const input = usage.inputTokens ?? usage.input ?? 0
129
+ const output = usage.outputTokens ?? usage.output ?? 0
130
+ if (typeof input === 'number' && typeof output === 'number') tokensUsed += input + output
131
+ if (!breached && tokensUsed > tokenCeiling()) {
132
+ breached = true
133
+ ctx.logger.warn(`pentest-team: session token budget exceeded (${String(tokensUsed)} > ${String(tokenCeiling())} tokens); interrupting ${String(children.size)} child agent(s)`)
134
+ for (const child of children.values()) {
135
+ if (child.status !== 'running') continue
136
+ if (rootAgent !== undefined) {
137
+ try {
138
+ subagents().interrupt(child.id as never, { kind: 'ancestor', agent: rootAgent })
139
+ } catch {
140
+ // Best-effort interruption; the child result settles the status.
141
+ }
142
+ }
143
+ child.status = 'stopped'
144
+ }
145
+ }
146
+ })
147
+
148
+ const handle: TeamHandle = {
149
+ children: () => [...children.values()],
150
+ isBreached: () => breached,
151
+ tokensUsed: () => tokensUsed,
152
+ }
153
+ ctx.provide('pentestTeam', handle)
154
+
155
+ const textBlock = (text: string): ContentBlock => ({ type: 'text', text })
156
+
157
+ ctx.tools.register(defineTool({
158
+ name: 'create_agent',
159
+ description: `Delegate a focused subtask to a specialist child agent. The child runs with this scan's preset and full tool surface. Pass skills (max 5, category/name) to point it at specialist knowledge. Do not run hands-on tests yourself that a child should run.`,
160
+ parameters: {
161
+ name: { type: 'string', required: true, description: 'Short specialist label (e.g. "recon", "web-sqli").' },
162
+ task: { type: 'string', required: true, description: 'The complete, self-contained task for the child.' },
163
+ skills: { type: 'array', items: { type: 'string' }, description: 'Up to 5 specialist skills (category/name) injected into the brief.' },
164
+ },
165
+ output: {
166
+ schema: {
167
+ type: 'object',
168
+ properties: {
169
+ success: { type: 'boolean', required: true },
170
+ agent_id: { type: 'string' },
171
+ status: { type: 'string' },
172
+ error: { type: 'string' },
173
+ },
174
+ additionalProperties: false,
175
+ },
176
+ render: (_args, value) => {
177
+ const result = value as { success: boolean; agent_id?: string; error?: string }
178
+ if (!result.success) return [{ type: 'text', text: `create_agent failed: ${result.error ?? 'unknown'}` }]
179
+ return [{ type: 'text', text: `child ${String(result.agent_id)} started` }]
180
+ },
181
+ },
182
+ execute: async (args, exec) => {
183
+ if (breached) return { success: false, error: `session token budget exhausted (${String(tokensUsed)} tokens) — wrap up and finish instead of spawning new agents` }
184
+ const runningCount = [...children.values()].filter(child => child.status === 'running').length
185
+ if (runningCount >= maxTeamDepth + 3) {
186
+ return { success: false, error: `too many concurrent children (${String(runningCount)}); wait_for_agents or stop_agent first` }
187
+ }
188
+ rootAgent ??= exec.agent
189
+ if (exec.agent !== undefined) teamSessions.add(String(exec.agent.session.id))
190
+ const brief: string[] = [`You are specialist agent "${args.name}" in an authorized penetration test.`, ``, `TASK:`, args.task]
191
+ if (args.skills !== undefined && args.skills.length > 0) {
192
+ brief.push('', 'SPECIALIST KNOWLEDGE: consult these skill areas and follow them.', ...args.skills.map(skill => `- ${skill}`))
193
+ }
194
+ brief.push('', 'Work autonomously. Report findings via create_vulnerability_report / create_dependency_report; record coverage with record_coverage; finish with a concise completion report as your final message.')
195
+ try {
196
+ const run = subagents().start('spawn', {
197
+ label: args.name,
198
+ prompt: [textBlock(brief.join('\n'))],
199
+ parent: exec.agent,
200
+ signal: exec.signal,
201
+ })
202
+ const id = idText(run.id)
203
+ // run.id is the child session id: its usage joins the team budget.
204
+ teamSessions.add(id)
205
+ const child: TrackedChild = {
206
+ id,
207
+ label: args.name,
208
+ task: args.task,
209
+ skills: args.skills ?? [],
210
+ startedAt: new Date().toISOString(),
211
+ status: 'running',
212
+ completionReport: undefined,
213
+ result: run.result,
214
+ }
215
+ children.set(id, child)
216
+ void run.result.then(outcome => {
217
+ if (child.status === 'running') {
218
+ child.status = outcome?.stopReason === 'error' ? 'failed' : 'completed'
219
+ }
220
+ // Fallback completion report: the child's final assistant text.
221
+ if (child.completionReport === undefined && outcome?.output !== undefined) {
222
+ const text = outcome.output.map(block => block.type === 'text' ? block.text : '').filter(part => part !== '').join('\n')
223
+ if (text !== '') child.completionReport = text
224
+ }
225
+ }, () => {
226
+ if (child.status === 'running') child.status = 'failed'
227
+ })
228
+ return { success: true, agent_id: id, status: 'running' }
229
+ } catch (error) {
230
+ return { success: false, error: String(error instanceof Error ? error.message : error) }
231
+ }
232
+ },
233
+ }))
234
+
235
+ ctx.tools.register(defineTool({
236
+ name: 'send_message_to_agent',
237
+ description: 'Steer a running child mid-run: new information, a course correction, or a request to wrap up. The message is delivered at the child\'s next step boundary.',
238
+ parameters: {
239
+ agent_id: { type: 'string', required: true, description: 'Child id from create_agent.' },
240
+ message: { type: 'string', required: true, description: 'The message text.' },
241
+ },
242
+ output: {
243
+ schema: {
244
+ type: 'object',
245
+ properties: {
246
+ success: { type: 'boolean', required: true },
247
+ error: { type: 'string' },
248
+ },
249
+ additionalProperties: false,
250
+ },
251
+ render: (_args, value) => {
252
+ const result = value as { success: boolean; error?: string }
253
+ return [{ type: 'text', text: result.success ? 'message delivered' : `send_message_to_agent failed: ${result.error ?? 'unknown'}` }]
254
+ },
255
+ },
256
+ execute: async (args, exec) => {
257
+ const child = children.get(args.agent_id)
258
+ if (child === undefined) return { success: false, error: `No child agent '${args.agent_id}'. Known: ${[...children.keys()].join(', ') || 'none'}.` }
259
+ try {
260
+ await subagents().sendMessage(exec.agent, args.agent_id as never, [textBlock(args.message)])
261
+ return { success: true }
262
+ } catch (error) {
263
+ return { success: false, error: String(error instanceof Error ? error.message : error) }
264
+ }
265
+ },
266
+ }))
267
+
268
+ ctx.tools.register(defineTool({
269
+ name: 'wait_for_agents',
270
+ description: 'Block until the named children report back (settlement notices also arrive automatically). Issue exactly ONE wait and react to what it returns.',
271
+ parameters: {
272
+ agent_ids: { type: 'array', items: { type: 'string' }, required: true, description: 'Child ids to wait for.' },
273
+ },
274
+ output: {
275
+ schema: {
276
+ type: 'object',
277
+ properties: {
278
+ success: { type: 'boolean', required: true },
279
+ agents: {
280
+ type: 'array',
281
+ required: true,
282
+ items: { type: 'object', properties: { agent_id: { type: 'string' }, status: { type: 'string' }, completion_report: { type: 'string' } }, additionalProperties: false },
283
+ },
284
+ error: { type: 'string' },
285
+ },
286
+ additionalProperties: false,
287
+ },
288
+ render: (_args, value) => {
289
+ const result = value as { agents?: Array<{ status: string }> }
290
+ return [{ type: 'text', text: `${String(result.agents?.length ?? 0)} child agent(s) settled` }]
291
+ },
292
+ },
293
+ execute: async args => {
294
+ const tracked = args.agent_ids.map(id => children.get(id)).filter((child): child is TrackedChild => child !== undefined)
295
+ if (tracked.length === 0) {
296
+ return { success: false, agents: [], error: `No known children among: ${args.agent_ids.join(', ')}` }
297
+ }
298
+ await Promise.all(tracked.map(child => child.result.catch(() => undefined)))
299
+ return {
300
+ success: true,
301
+ agents: tracked.map(child => ({
302
+ agent_id: child.id,
303
+ status: child.status,
304
+ ...(child.completionReport !== undefined ? { completion_report: child.completionReport } : {}),
305
+ })),
306
+ }
307
+ },
308
+ }))
309
+
310
+ ctx.tools.register(defineTool({
311
+ name: 'view_agent_graph',
312
+ description: 'Your live map of the team: every child agent with its id, label, task, skills, and status. Call it before spawning (avoid duplicates) and before finishing (no child still running).',
313
+ parameters: {},
314
+ output: {
315
+ schema: {
316
+ type: 'object',
317
+ properties: {
318
+ success: { type: 'boolean', required: true },
319
+ agents: {
320
+ type: 'array',
321
+ required: true,
322
+ items: {
323
+ type: 'object',
324
+ properties: {
325
+ agent_id: { type: 'string', required: true },
326
+ name: { type: 'string' },
327
+ task: { type: 'string' },
328
+ skills: { type: 'array', items: { type: 'string' } },
329
+ status: { type: 'string', required: true },
330
+ started_at: { type: 'string' },
331
+ },
332
+ additionalProperties: false,
333
+ },
334
+ },
335
+ tokens_used: { type: 'integer' },
336
+ budget_breached: { type: 'boolean' },
337
+ },
338
+ additionalProperties: false,
339
+ },
340
+ render: (_args, value) => {
341
+ const result = value as { agents: Array<{ name?: string; status: string }>; tokens_used: number }
342
+ return [{ type: 'text', text: `${String(result.agents.length)} child agent(s), ${String(result.tokens_used)} tokens used` }]
343
+ },
344
+ },
345
+ execute: async () => ({
346
+ success: true,
347
+ agents: [...children.values()].map(child => ({
348
+ agent_id: child.id,
349
+ name: child.label,
350
+ task: child.task,
351
+ skills: [...child.skills],
352
+ status: child.status,
353
+ started_at: child.startedAt,
354
+ })),
355
+ tokens_used: tokensUsed,
356
+ budget_breached: breached,
357
+ }),
358
+ }))
359
+
360
+ ctx.tools.register(defineTool({
361
+ name: 'stop_agent',
362
+ description: 'Gracefully cancel a child whose work is redundant or misdirected. Prefer send_message_to_agent to redirect a child that is merely off-track.',
363
+ parameters: {
364
+ agent_id: { type: 'string', required: true, description: 'Child id to cancel.' },
365
+ },
366
+ output: {
367
+ schema: {
368
+ type: 'object',
369
+ properties: {
370
+ success: { type: 'boolean', required: true },
371
+ error: { type: 'string' },
372
+ },
373
+ additionalProperties: false,
374
+ },
375
+ render: (_args, value) => {
376
+ const result = value as { success: boolean; error?: string }
377
+ return [{ type: 'text', text: result.success ? 'child cancelled' : `stop_agent failed: ${result.error ?? 'unknown'}` }]
378
+ },
379
+ },
380
+ execute: async (args, exec) => {
381
+ const child = children.get(args.agent_id)
382
+ if (child === undefined) return { success: false, error: `No child agent '${args.agent_id}'.` }
383
+ if (child.status !== 'running') return { success: false, error: `Child '${args.agent_id}' is already ${child.status}.` }
384
+ try {
385
+ const parentSessionId = exec.agent?.session.id
386
+ subagents().interrupt(args.agent_id as never, { kind: 'user', parentSessionId })
387
+ child.status = 'stopped'
388
+ return { success: true }
389
+ } catch (error) {
390
+ return { success: false, error: String(error instanceof Error ? error.message : error) }
391
+ }
392
+ },
393
+ }))
394
+
395
+ ctx.tools.register(defineTool({
396
+ name: 'agent_finish',
397
+ description: 'Child agents: submit your structured completion summary and end your turn. The final message IS the completion report the parent receives — this tool records that you are done.',
398
+ parameters: {
399
+ summary: { type: 'string', required: true, description: 'The structured completion report (findings, coverage, open items).' },
400
+ },
401
+ output: {
402
+ schema: {
403
+ type: 'object',
404
+ properties: {
405
+ success: { type: 'boolean', required: true },
406
+ message: { type: 'string' },
407
+ },
408
+ additionalProperties: false,
409
+ },
410
+ render: (_args, value) => {
411
+ const result = value as { message?: string }
412
+ return [{ type: 'text', text: String(result.message ?? 'completion recorded') }]
413
+ },
414
+ },
415
+ execute: async (args, exec) => {
416
+ // agent_finish executes inside the CHILD agent; its session id is the
417
+ // create_agent run id, so the summary lands on the tracked roster row.
418
+ const sessionKey = exec.agent === undefined ? undefined : String(exec.agent.session.id)
419
+ const child = sessionKey === undefined ? undefined : children.get(sessionKey)
420
+ if (child !== undefined && child.completionReport === undefined) child.completionReport = args.summary
421
+ return { success: true, message: `Completion recorded (${String(args.summary.length)} chars). End your turn now — your final message is the report.` }
422
+ },
423
+ }))
424
+
425
+ return handle
426
+ }
427
+