@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 +1 -1
- package/unify/personas.js +134 -0
- package/unify/tools/agent.js +211 -14
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/unify/tools/agent.js
CHANGED
|
@@ -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
|
|
24
|
-
|
|
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
|
|
28
|
-
- Use
|
|
29
|
-
-
|
|
30
|
-
- Use SendMessage to communicate
|
|
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: '
|
|
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'
|
|
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
|
|
55
|
-
if (!
|
|
56
|
-
|
|
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
|
-
|
|
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
|
},
|