@yeaft/webchat-agent 0.1.466 → 0.1.468

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": "@yeaft/webchat-agent",
3
- "version": "0.1.466",
3
+ "version": "0.1.468",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,134 @@
1
+ /**
2
+ * personas.js — Preset persona loader for sub-agents.
3
+ *
4
+ * A persona defines a sub-agent's role: which tools it can use, which
5
+ * model tier it runs on, and what system prompt it carries.
6
+ *
7
+ * Presets live as markdown files with YAML frontmatter in
8
+ * `agent/unify/templates/personas/*.md`.
9
+ */
10
+
11
+ import { readFileSync, readdirSync } from 'fs';
12
+ import { fileURLToPath } from 'url';
13
+ import { dirname, join } from 'path';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const PERSONAS_DIR = join(__dirname, 'templates', 'personas');
18
+
19
+ /**
20
+ * @typedef {Object} Persona
21
+ * @property {string} id
22
+ * @property {string} name
23
+ * @property {string} description
24
+ * @property {'fast'|'primary'} modelTier
25
+ * @property {string[]} tools
26
+ * @property {string} systemPrompt
27
+ */
28
+
29
+ /** @type {Map<string, Persona>|null} */
30
+ let cached = null;
31
+
32
+ /**
33
+ * Parse YAML frontmatter + body from a markdown file.
34
+ * Minimal parser for the fields we use (no external dep).
35
+ *
36
+ * @param {string} source
37
+ * @returns {{ meta: object, body: string }}
38
+ */
39
+ export function parseFrontmatter(source) {
40
+ const match = source.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
41
+ if (!match) return { meta: {}, body: source };
42
+
43
+ const [, yaml, body] = match;
44
+ const meta = {};
45
+ const lines = yaml.split('\n');
46
+ let currentKey = null;
47
+ let currentList = null;
48
+
49
+ for (const line of lines) {
50
+ if (!line.trim()) continue;
51
+ const listMatch = line.match(/^\s+-\s+(.+?)\s*$/);
52
+ if (listMatch && currentList) {
53
+ currentList.push(listMatch[1]);
54
+ continue;
55
+ }
56
+ const kvMatch = line.match(/^([\w-]+):\s*(.*)$/);
57
+ if (kvMatch) {
58
+ const [, key, value] = kvMatch;
59
+ if (!value.trim()) {
60
+ currentList = [];
61
+ meta[key] = currentList;
62
+ currentKey = key;
63
+ } else {
64
+ meta[key] = value.trim();
65
+ currentKey = key;
66
+ currentList = null;
67
+ }
68
+ }
69
+ }
70
+
71
+ return { meta, body: body.trim() };
72
+ }
73
+
74
+ /**
75
+ * Load all built-in personas from templates/personas/*.md.
76
+ *
77
+ * @param {{ dir?: string, fresh?: boolean }} [options]
78
+ * @returns {Map<string, Persona>}
79
+ */
80
+ export function loadPersonas(options = {}) {
81
+ const { dir = PERSONAS_DIR, fresh = false } = options;
82
+ if (!fresh && cached) return cached;
83
+
84
+ const map = new Map();
85
+ let files;
86
+ try {
87
+ files = readdirSync(dir).filter(f => f.endsWith('.md'));
88
+ } catch {
89
+ if (!fresh) cached = map;
90
+ return map;
91
+ }
92
+
93
+ for (const file of files) {
94
+ try {
95
+ const source = readFileSync(join(dir, file), 'utf-8');
96
+ const { meta, body } = parseFrontmatter(source);
97
+ if (!meta.id) continue;
98
+ const persona = {
99
+ id: String(meta.id),
100
+ name: String(meta.name || meta.id),
101
+ description: String(meta.description || ''),
102
+ modelTier: meta.modelTier === 'primary' ? 'primary' : 'fast',
103
+ tools: Array.isArray(meta.tools) ? meta.tools.map(String) : [],
104
+ systemPrompt: body,
105
+ };
106
+ map.set(persona.id, persona);
107
+ } catch {
108
+ // skip bad files
109
+ }
110
+ }
111
+
112
+ if (!fresh) cached = map;
113
+ return map;
114
+ }
115
+
116
+ /**
117
+ * Get a persona by id. Returns undefined if not found.
118
+ * @param {string} id
119
+ * @returns {Persona|undefined}
120
+ */
121
+ export function getPersona(id) {
122
+ if (!id) return undefined;
123
+ return loadPersonas().get(id);
124
+ }
125
+
126
+ /** @returns {string[]} */
127
+ export function listPersonaIds() {
128
+ return Array.from(loadPersonas().keys());
129
+ }
130
+
131
+ /** Clear cache (for tests). */
132
+ export function _resetPersonaCache() {
133
+ cached = null;
134
+ }
@@ -3,10 +3,26 @@
3
3
  *
4
4
  * Sub-agents run in isolated contexts and can be assigned
5
5
  * independent tasks. They communicate via send-message/wait-agent.
6
+ *
7
+ * SubagentSpec contract (v1):
8
+ * {
9
+ * name: string,
10
+ * task: string, // legacy summary; becomes mission when not given
11
+ * mission?: string, // concrete objective statement
12
+ * expected_output?: object, // JSON schema describing the required output
13
+ * persona?: string, // preset id: explorer|implementer|reviewer|researcher
14
+ * budget?: {
15
+ * max_tokens?: number,
16
+ * max_turns?: number,
17
+ * wall_time_ms?: number
18
+ * },
19
+ * cwd?: string
20
+ * }
6
21
  */
7
22
 
8
23
  import { defineTool } from './types.js';
9
24
  import { randomUUID } from 'crypto';
25
+ import { getPersona, listPersonaIds } from '../personas.js';
10
26
 
11
27
  /** In-memory sub-agent registry. */
12
28
  const agents = new Map();
@@ -16,19 +32,163 @@ export function getAgentRegistry() {
16
32
  return agents;
17
33
  }
18
34
 
35
+ /** Reset registry (for tests). */
36
+ export function _resetAgentRegistry() {
37
+ agents.clear();
38
+ }
39
+
40
+ /**
41
+ * Validate a SubagentSpec. Returns { ok: true, spec } or { ok: false, error }.
42
+ *
43
+ * @param {object} input
44
+ */
45
+ export function validateSpec(input) {
46
+ if (!input || typeof input !== 'object') {
47
+ return { ok: false, error: 'spec must be an object' };
48
+ }
49
+ const { name, task, mission, expected_output, persona, budget } = input;
50
+ if (!name || typeof name !== 'string') {
51
+ return { ok: false, error: 'name is required' };
52
+ }
53
+ if (!task && !mission) {
54
+ return { ok: false, error: 'task or mission is required' };
55
+ }
56
+ if (persona && !getPersona(persona)) {
57
+ return {
58
+ ok: false,
59
+ error: `unknown persona "${persona}"; available: ${listPersonaIds().join(', ')}`,
60
+ };
61
+ }
62
+ if (expected_output !== undefined && (typeof expected_output !== 'object' || expected_output === null)) {
63
+ return { ok: false, error: 'expected_output must be a JSON schema object' };
64
+ }
65
+ if (budget !== undefined) {
66
+ if (typeof budget !== 'object' || budget === null) {
67
+ return { ok: false, error: 'budget must be an object' };
68
+ }
69
+ for (const k of ['max_tokens', 'max_turns', 'wall_time_ms']) {
70
+ if (budget[k] !== undefined && (typeof budget[k] !== 'number' || budget[k] <= 0)) {
71
+ return { ok: false, error: `budget.${k} must be a positive number` };
72
+ }
73
+ }
74
+ }
75
+ return {
76
+ ok: true,
77
+ spec: {
78
+ name,
79
+ mission: mission || task,
80
+ task: task || mission,
81
+ expected_output: expected_output || null,
82
+ persona: persona || null,
83
+ budget: budget || null,
84
+ },
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Check a sub-agent's budget against current usage.
90
+ * Returns { exceeded: true, reason } when any bound is breached, else { exceeded: false }.
91
+ *
92
+ * @param {{ budget: object|null, usage: { tokens: number, turns: number, startedAt: number } }} agent
93
+ * @param {number} [now=Date.now()]
94
+ */
95
+ export function checkBudget(agent, now = Date.now()) {
96
+ const b = agent.budget;
97
+ if (!b) return { exceeded: false };
98
+ const u = agent.usage || { tokens: 0, turns: 0, startedAt: now };
99
+ if (b.max_tokens !== undefined && u.tokens >= b.max_tokens) {
100
+ return { exceeded: true, reason: `max_tokens (${b.max_tokens}) reached`, limit: 'max_tokens' };
101
+ }
102
+ if (b.max_turns !== undefined && u.turns >= b.max_turns) {
103
+ return { exceeded: true, reason: `max_turns (${b.max_turns}) reached`, limit: 'max_turns' };
104
+ }
105
+ if (b.wall_time_ms !== undefined && (now - u.startedAt) >= b.wall_time_ms) {
106
+ return { exceeded: true, reason: `wall_time_ms (${b.wall_time_ms}) exceeded`, limit: 'wall_time_ms' };
107
+ }
108
+ return { exceeded: false };
109
+ }
110
+
111
+ /**
112
+ * Build a budget-exceeded result envelope.
113
+ * @param {object} agent
114
+ * @param {string} reason
115
+ */
116
+ export function budgetExceededResult(agent, reason) {
117
+ return {
118
+ status: 'budget_exceeded',
119
+ partial_output: agent.partial_output || agent.result || '',
120
+ reason,
121
+ usage: { ...(agent.usage || {}) },
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Apply an incremental delta to an agent's usage, then check budget.
127
+ * If exceeded: abort the agent's signal, set result to the budget envelope,
128
+ * flip status to 'completed', and return the envelope. Otherwise returns null.
129
+ *
130
+ * Call this at each turn boundary inside the sub-agent's execution loop.
131
+ *
132
+ * @param {string} agentId
133
+ * @param {{ tokens?: number, turns?: number, partial_output?: string }} [delta]
134
+ * @param {number} [now=Date.now()]
135
+ * @returns {object|null} — budget envelope if exceeded, else null
136
+ */
137
+ export function tickAgent(agentId, delta = {}, now = Date.now()) {
138
+ const agent = agents.get(agentId);
139
+ if (!agent) return null;
140
+ if (agent.status === 'completed' || agent.status === 'closed') return null;
141
+
142
+ if (typeof delta.tokens === 'number' && delta.tokens > 0) {
143
+ agent.usage.tokens += delta.tokens;
144
+ }
145
+ if (typeof delta.turns === 'number' && delta.turns > 0) {
146
+ agent.usage.turns += delta.turns;
147
+ }
148
+ if (typeof delta.partial_output === 'string' && delta.partial_output) {
149
+ agent.partial_output = delta.partial_output;
150
+ }
151
+
152
+ const check = checkBudget(agent, now);
153
+ if (!check.exceeded) return null;
154
+
155
+ const envelope = budgetExceededResult(agent, check.reason);
156
+ agent.result = envelope;
157
+ agent.status = 'completed';
158
+ agent.diagnostics.push({
159
+ type: 'budget_exceeded',
160
+ limit: check.limit,
161
+ reason: check.reason,
162
+ at: now,
163
+ });
164
+ // Signal any in-flight sub-agent work to stop
165
+ if (agent.abortController && !agent.abortController.signal.aborted) {
166
+ try {
167
+ agent.abortController.abort(check.reason);
168
+ } catch {
169
+ // ignore double-abort
170
+ }
171
+ }
172
+ return envelope;
173
+ }
174
+
19
175
  export default defineTool({
20
176
  name: 'Agent',
21
177
  description: `Create a sub-agent to work on an independent task in parallel.
22
178
 
23
- Sub-agents run in their own context and can be given specific tasks.
24
- Use for parallel execution of independent subtasks.
179
+ Sub-agents run in their own context and can be given a concrete mission
180
+ with an expected_output schema and a budget (max_tokens/max_turns/wall_time_ms).
181
+ Pick a preset persona to pre-wire a tool subset and model tier:
182
+ - explorer : fast, read-only scout (Read/Grep/Glob/ListDir)
183
+ - implementer: builder with full work tools (primary model)
184
+ - reviewer : read-only critic (primary model)
185
+ - researcher : web-facing info gatherer (WebSearch/WebFetch/Read)
25
186
 
26
187
  Guidelines:
27
- - Give each agent a clear, focused task description
28
- - Use unique, descriptive names
29
- - Sub-agents share the same tools but have independent conversations
30
- - Use SendMessage to communicate with agents, WaitAgent to collect results
31
- - Close agents with CloseAgent when done`,
188
+ - Give a clear, focused mission — what "done" looks like
189
+ - Use expected_output to pin the structure you want back
190
+ - Always set a budget for unbounded missions
191
+ - Use SendMessage to communicate, WaitAgent to collect results, CloseAgent to finalize`,
32
192
  parameters: {
33
193
  type: 'object',
34
194
  properties: {
@@ -38,22 +198,45 @@ Guidelines:
38
198
  },
39
199
  task: {
40
200
  type: 'string',
41
- description: 'The task description for the sub-agent',
201
+ description: 'Legacy one-line task description (used if `mission` is omitted)',
202
+ },
203
+ mission: {
204
+ type: 'string',
205
+ description: 'Concrete mission statement — what this agent must accomplish',
206
+ },
207
+ expected_output: {
208
+ type: 'object',
209
+ description: 'JSON schema describing the structure the agent should return',
210
+ },
211
+ persona: {
212
+ type: 'string',
213
+ enum: ['explorer', 'implementer', 'reviewer', 'researcher'],
214
+ description: 'Preset persona that pre-wires tool subset + model tier',
215
+ },
216
+ budget: {
217
+ type: 'object',
218
+ properties: {
219
+ max_tokens: { type: 'number' },
220
+ max_turns: { type: 'number' },
221
+ wall_time_ms: { type: 'number' },
222
+ },
223
+ description: 'Budget limits; exceeding any returns { status: "budget_exceeded", partial_output, reason }',
42
224
  },
43
225
  cwd: {
44
226
  type: 'string',
45
227
  description: 'Working directory for the sub-agent (optional, defaults to parent cwd)',
46
228
  },
47
229
  },
48
- required: ['name', 'task'],
230
+ required: ['name'],
49
231
  },
50
232
  modes: ['work'],
51
233
  isConcurrencySafe: () => false,
52
234
  isReadOnly: () => false,
53
235
  async execute(input, ctx) {
54
- const { name, task, cwd } = input;
55
- if (!name) return JSON.stringify({ error: 'name is required' });
56
- if (!task) return JSON.stringify({ error: 'task is required' });
236
+ const validation = validateSpec(input);
237
+ if (!validation.ok) return JSON.stringify({ error: validation.error });
238
+ const spec = validation.spec;
239
+ const { name, cwd } = input;
57
240
 
58
241
  // Check for name collision
59
242
  for (const [, agent] of agents) {
@@ -65,16 +248,28 @@ Guidelines:
65
248
  }
66
249
  }
67
250
 
251
+ const persona = spec.persona ? getPersona(spec.persona) : null;
252
+ const now = Date.now();
68
253
  const agentId = `agent-${randomUUID().slice(0, 8)}`;
69
254
  const agent = {
70
255
  id: agentId,
71
256
  name,
72
- task,
257
+ task: spec.task,
258
+ mission: spec.mission,
259
+ expected_output: spec.expected_output,
260
+ persona: spec.persona,
261
+ personaData: persona || null,
262
+ budget: spec.budget,
73
263
  cwd: cwd || ctx?.cwd || process.cwd(),
74
264
  status: 'created',
75
265
  messages: [],
76
266
  result: null,
77
- createdAt: Date.now(),
267
+ partial_output: '',
268
+ diagnostics: [],
269
+ usage: { tokens: 0, turns: 0, startedAt: now },
270
+ createdAt: now,
271
+ trace: [],
272
+ abortController: new AbortController(),
78
273
  };
79
274
 
80
275
  agents.set(agentId, agent);
@@ -83,6 +278,8 @@ Guidelines:
83
278
  success: true,
84
279
  agentId,
85
280
  name,
281
+ persona: spec.persona || null,
282
+ budget: spec.budget || null,
86
283
  message: `Sub-agent "${name}" created (${agentId}). Use SendMessage to give it work.`,
87
284
  });
88
285
  },