@yeaft/webchat-agent 0.1.466 → 0.1.467

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.467",
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,113 @@ 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
+
19
125
  export default defineTool({
20
126
  name: 'Agent',
21
127
  description: `Create a sub-agent to work on an independent task in parallel.
22
128
 
23
- Sub-agents run in their own context and can be given specific tasks.
24
- Use for parallel execution of independent subtasks.
129
+ Sub-agents run in their own context and can be given a concrete mission
130
+ with an expected_output schema and a budget (max_tokens/max_turns/wall_time_ms).
131
+ Pick a preset persona to pre-wire a tool subset and model tier:
132
+ - explorer : fast, read-only scout (Read/Grep/Glob/ListDir)
133
+ - implementer: builder with full work tools (primary model)
134
+ - reviewer : read-only critic (primary model)
135
+ - researcher : web-facing info gatherer (WebSearch/WebFetch/Read)
25
136
 
26
137
  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`,
138
+ - Give a clear, focused mission — what "done" looks like
139
+ - Use expected_output to pin the structure you want back
140
+ - Always set a budget for unbounded missions
141
+ - Use SendMessage to communicate, WaitAgent to collect results, CloseAgent to finalize`,
32
142
  parameters: {
33
143
  type: 'object',
34
144
  properties: {
@@ -38,22 +148,45 @@ Guidelines:
38
148
  },
39
149
  task: {
40
150
  type: 'string',
41
- description: 'The task description for the sub-agent',
151
+ description: 'Legacy one-line task description (used if `mission` is omitted)',
152
+ },
153
+ mission: {
154
+ type: 'string',
155
+ description: 'Concrete mission statement — what this agent must accomplish',
156
+ },
157
+ expected_output: {
158
+ type: 'object',
159
+ description: 'JSON schema describing the structure the agent should return',
160
+ },
161
+ persona: {
162
+ type: 'string',
163
+ enum: ['explorer', 'implementer', 'reviewer', 'researcher'],
164
+ description: 'Preset persona that pre-wires tool subset + model tier',
165
+ },
166
+ budget: {
167
+ type: 'object',
168
+ properties: {
169
+ max_tokens: { type: 'number' },
170
+ max_turns: { type: 'number' },
171
+ wall_time_ms: { type: 'number' },
172
+ },
173
+ description: 'Budget limits; exceeding any returns { status: "budget_exceeded", partial_output, reason }',
42
174
  },
43
175
  cwd: {
44
176
  type: 'string',
45
177
  description: 'Working directory for the sub-agent (optional, defaults to parent cwd)',
46
178
  },
47
179
  },
48
- required: ['name', 'task'],
180
+ required: ['name'],
49
181
  },
50
182
  modes: ['work'],
51
183
  isConcurrencySafe: () => false,
52
184
  isReadOnly: () => false,
53
185
  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' });
186
+ const validation = validateSpec(input);
187
+ if (!validation.ok) return JSON.stringify({ error: validation.error });
188
+ const spec = validation.spec;
189
+ const { name, cwd } = input;
57
190
 
58
191
  // Check for name collision
59
192
  for (const [, agent] of agents) {
@@ -65,16 +198,27 @@ Guidelines:
65
198
  }
66
199
  }
67
200
 
201
+ const persona = spec.persona ? getPersona(spec.persona) : null;
202
+ const now = Date.now();
68
203
  const agentId = `agent-${randomUUID().slice(0, 8)}`;
69
204
  const agent = {
70
205
  id: agentId,
71
206
  name,
72
- task,
207
+ task: spec.task,
208
+ mission: spec.mission,
209
+ expected_output: spec.expected_output,
210
+ persona: spec.persona,
211
+ personaData: persona || null,
212
+ budget: spec.budget,
73
213
  cwd: cwd || ctx?.cwd || process.cwd(),
74
214
  status: 'created',
75
215
  messages: [],
76
216
  result: null,
77
- createdAt: Date.now(),
217
+ partial_output: '',
218
+ diagnostics: [],
219
+ usage: { tokens: 0, turns: 0, startedAt: now },
220
+ createdAt: now,
221
+ trace: [],
78
222
  };
79
223
 
80
224
  agents.set(agentId, agent);
@@ -83,6 +227,8 @@ Guidelines:
83
227
  success: true,
84
228
  agentId,
85
229
  name,
230
+ persona: spec.persona || null,
231
+ budget: spec.budget || null,
86
232
  message: `Sub-agent "${name}" created (${agentId}). Use SendMessage to give it work.`,
87
233
  });
88
234
  },