agent-planner-mcp 0.8.1 → 0.9.0

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.
@@ -0,0 +1,532 @@
1
+ /**
2
+ * BDI intentions — committed actions.
3
+ *
4
+ * v0.9.0 ships queue_decision, resolve_decision, and update_task first.
5
+ * Other intention tools (claim_next_task, release_task, add_learning) land in
6
+ * subsequent passes.
7
+ */
8
+
9
+ const { asOf, formatResponse, errorResponse } = require('./_shared');
10
+
11
+ // ─────────────────────────────────────────────────────────────────────────
12
+ // queue_decision — real decision queue. Replaces add_learning workaround.
13
+ // ─────────────────────────────────────────────────────────────────────────
14
+
15
+ const queueDecisionDefinition = {
16
+ name: 'queue_decision',
17
+ description:
18
+ "Queue a decision for human review. Writes to the real decisions table " +
19
+ "(not the knowledge graph). Replaces the autopilot pattern of calling " +
20
+ "add_learning with entry_type=decision and a 'DECISION NEEDED:' title prefix. " +
21
+ "Resolves via resolve_decision.",
22
+ inputSchema: {
23
+ type: 'object',
24
+ properties: {
25
+ plan_id: {
26
+ type: 'string',
27
+ description: "Plan that owns this decision. Required if node_id is not provided.",
28
+ },
29
+ node_id: {
30
+ type: 'string',
31
+ description: "Task that prompted the decision. If provided, plan_id is inferred.",
32
+ },
33
+ title: { type: 'string', description: 'User-facing decision title' },
34
+ context: { type: 'string', description: 'Background — why this matters, what is at stake' },
35
+ options: {
36
+ type: 'array',
37
+ items: {
38
+ type: 'object',
39
+ properties: {
40
+ label: { type: 'string' },
41
+ description: { type: 'string' },
42
+ },
43
+ required: ['label'],
44
+ },
45
+ description: 'Concrete options to choose between',
46
+ },
47
+ recommendation: {
48
+ type: 'string',
49
+ description: 'Agent\'s preferred option with one-line reasoning',
50
+ },
51
+ smallest_input_needed: {
52
+ type: 'string',
53
+ description: "Explicit ask for human, e.g. 'approve|defer'",
54
+ },
55
+ urgency: {
56
+ type: 'string',
57
+ enum: ['low', 'normal', 'high'],
58
+ default: 'normal',
59
+ },
60
+ goal_id: { type: 'string', description: 'Optional goal this decision serves' },
61
+ },
62
+ required: ['title', 'context', 'smallest_input_needed'],
63
+ },
64
+ };
65
+
66
+ async function queueDecisionHandler(args, apiClient) {
67
+ const { plan_id, node_id, title, context, options, recommendation, smallest_input_needed, urgency, goal_id } = args;
68
+
69
+ let planId = plan_id;
70
+ if (!planId && node_id) {
71
+ try {
72
+ const node = await apiClient.axiosInstance.get(`/nodes/${node_id}`).then((r) => r.data);
73
+ planId = node.plan_id || node.planId;
74
+ } catch (err) {
75
+ return errorResponse('not_found', `Could not resolve plan_id from node_id ${node_id}: ${err.message}`);
76
+ }
77
+ }
78
+ if (!planId) {
79
+ return errorResponse('invalid_arg', 'queue_decision requires either plan_id or node_id');
80
+ }
81
+
82
+ const body = {
83
+ title,
84
+ context,
85
+ options: options || [],
86
+ recommendation: recommendation || null,
87
+ urgency: urgency || 'normal',
88
+ metadata: { smallest_input_needed, goal_id: goal_id || null, source: 'bdi.queue_decision' },
89
+ };
90
+ if (node_id) body.node_id = node_id;
91
+
92
+ try {
93
+ const created = await apiClient.axiosInstance
94
+ .post(`/plans/${planId}/decisions`, body)
95
+ .then((r) => r.data);
96
+ return formatResponse({
97
+ as_of: asOf(),
98
+ decision_id: created.id,
99
+ plan_id: planId,
100
+ node_id: node_id || null,
101
+ status: created.status || 'pending',
102
+ title: created.title,
103
+ });
104
+ } catch (err) {
105
+ return errorResponse(
106
+ 'upstream_unavailable',
107
+ `Failed to queue decision: ${err.response?.data?.error || err.message}`
108
+ );
109
+ }
110
+ }
111
+
112
+ // ─────────────────────────────────────────────────────────────────────────
113
+ // resolve_decision — mark a queued decision as approved/deferred/rejected.
114
+ // ─────────────────────────────────────────────────────────────────────────
115
+
116
+ const resolveDecisionDefinition = {
117
+ name: 'resolve_decision',
118
+ description:
119
+ "Resolve a pending decision. action is 'approve', 'defer', or 'reject'. " +
120
+ "Use this from Cowork artifact buttons or after a human responds in chat.",
121
+ inputSchema: {
122
+ type: 'object',
123
+ properties: {
124
+ decision_id: { type: 'string' },
125
+ plan_id: {
126
+ type: 'string',
127
+ description: 'Plan that owns the decision (required by API path)',
128
+ },
129
+ action: {
130
+ type: 'string',
131
+ enum: ['approve', 'defer', 'reject'],
132
+ },
133
+ message: { type: 'string', description: 'Optional resolution note' },
134
+ selected_option: {
135
+ type: 'string',
136
+ description: 'When the decision presented options, which was chosen',
137
+ },
138
+ },
139
+ required: ['decision_id', 'plan_id', 'action'],
140
+ },
141
+ };
142
+
143
+ async function resolveDecisionHandler(args, apiClient) {
144
+ const { decision_id, plan_id, action, message, selected_option } = args;
145
+ try {
146
+ const resolved = await apiClient.axiosInstance
147
+ .post(`/plans/${plan_id}/decisions/${decision_id}/resolve`, {
148
+ resolution: action,
149
+ message: message || null,
150
+ selected_option: selected_option || null,
151
+ })
152
+ .then((r) => r.data);
153
+ return formatResponse({
154
+ as_of: asOf(),
155
+ decision_id,
156
+ plan_id,
157
+ status: resolved.status || action,
158
+ resolved_at: resolved.resolved_at || asOf(),
159
+ message: resolved.message || message || null,
160
+ });
161
+ } catch (err) {
162
+ return errorResponse(
163
+ 'upstream_unavailable',
164
+ `Failed to resolve decision: ${err.response?.data?.error || err.message}`
165
+ );
166
+ }
167
+ }
168
+
169
+ // ─────────────────────────────────────────────────────────────────────────
170
+ // update_task — atomic state transition. Replaces 3 calls.
171
+ // ─────────────────────────────────────────────────────────────────────────
172
+
173
+ const STATUS_TO_LOG_TYPE = {
174
+ blocked: 'challenge',
175
+ completed: 'progress',
176
+ in_progress: 'progress',
177
+ not_started: 'progress',
178
+ plan_ready: 'progress',
179
+ };
180
+
181
+ const updateTaskDefinition = {
182
+ name: 'update_task',
183
+ description:
184
+ "Atomic task state transition. Updates status, optionally appends a log " +
185
+ "entry, optionally releases the claim. Idempotent on identical inputs. " +
186
+ "Replaces quick_status + add_log + release_task fan-out.",
187
+ inputSchema: {
188
+ type: 'object',
189
+ properties: {
190
+ task_id: { type: 'string' },
191
+ plan_id: {
192
+ type: 'string',
193
+ description: 'Plan that owns the task (auto-resolved from task if omitted)',
194
+ },
195
+ status: {
196
+ type: 'string',
197
+ enum: ['not_started', 'in_progress', 'completed', 'blocked', 'plan_ready'],
198
+ },
199
+ log_message: { type: 'string', description: 'Optional progress note' },
200
+ log_type: {
201
+ type: 'string',
202
+ enum: ['progress', 'decision', 'blocker', 'completion', 'challenge'],
203
+ description: "Defaults from status: blocked→challenge, others→progress.",
204
+ },
205
+ release_claim: {
206
+ type: 'boolean',
207
+ description: "Default: auto (true if status is completed/blocked). Set explicitly to override.",
208
+ },
209
+ add_learning: {
210
+ type: 'string',
211
+ description: 'Optional: also write a knowledge episode (recommended on completion)',
212
+ },
213
+ },
214
+ required: ['task_id'],
215
+ },
216
+ };
217
+
218
+ async function updateTaskHandler(args, apiClient) {
219
+ const { task_id, status, log_message, add_learning, release_claim } = args;
220
+ let planId = args.plan_id;
221
+
222
+ // Resolve plan_id from task if not provided.
223
+ if (!planId) {
224
+ try {
225
+ const node = await apiClient.axiosInstance.get(`/nodes/${task_id}`).then((r) => r.data);
226
+ planId = node.plan_id || node.planId;
227
+ } catch (err) {
228
+ return errorResponse('not_found', `Could not resolve plan_id from task ${task_id}: ${err.message}`);
229
+ }
230
+ }
231
+
232
+ const result = {
233
+ as_of: asOf(),
234
+ task_id,
235
+ plan_id: planId,
236
+ applied: { status_changed: false, log_added: false, claim_released: false, learning_recorded: false },
237
+ failures: [],
238
+ };
239
+
240
+ // 1. Status update
241
+ if (status) {
242
+ try {
243
+ await apiClient.nodes.updateNode(planId, task_id, { status });
244
+ result.applied.status_changed = true;
245
+ result.status = status;
246
+ } catch (err) {
247
+ result.failures.push({ step: 'update_status', error: err.response?.data?.error || err.message });
248
+ }
249
+ }
250
+
251
+ // 2. Log entry
252
+ if (log_message) {
253
+ const logType = args.log_type || STATUS_TO_LOG_TYPE[status] || 'progress';
254
+ try {
255
+ const log = await apiClient.logs.addLog(planId, task_id, {
256
+ content: log_message,
257
+ log_type: logType,
258
+ });
259
+ result.applied.log_added = true;
260
+ result.log_id = log?.id || log?.log?.id;
261
+ } catch (err) {
262
+ result.failures.push({ step: 'add_log', error: err.response?.data?.error || err.message });
263
+ }
264
+ }
265
+
266
+ // 3. Claim release — auto if status is terminal, explicit override otherwise.
267
+ const shouldRelease =
268
+ typeof release_claim === 'boolean'
269
+ ? release_claim
270
+ : status === 'completed' || status === 'blocked';
271
+ if (shouldRelease) {
272
+ try {
273
+ await apiClient.axiosInstance.delete(`/nodes/${task_id}/claim`);
274
+ result.applied.claim_released = true;
275
+ } catch (err) {
276
+ // Releasing an unclaimed task is not a hard error — just record it.
277
+ result.failures.push({ step: 'release_claim', error: err.response?.data?.error || err.message });
278
+ }
279
+ }
280
+
281
+ // 4. Optional learning write to knowledge graph.
282
+ if (add_learning) {
283
+ try {
284
+ await apiClient.graphiti.addEpisode({
285
+ name: `Task: ${task_id}`,
286
+ content: add_learning,
287
+ source: 'task_update',
288
+ plan_id: planId,
289
+ node_id: task_id,
290
+ });
291
+ result.applied.learning_recorded = true;
292
+ } catch (err) {
293
+ result.failures.push({ step: 'add_learning', error: err.response?.data?.error || err.message });
294
+ }
295
+ }
296
+
297
+ return formatResponse(result);
298
+ }
299
+
300
+ // ─────────────────────────────────────────────────────────────────────────
301
+ // claim_next_task — bundle suggest + claim + load context.
302
+ // ─────────────────────────────────────────────────────────────────────────
303
+
304
+ const claimNextTaskDefinition = {
305
+ name: 'claim_next_task',
306
+ description:
307
+ "Pick the next task in scope, claim it, and return its context — all in " +
308
+ "one call. Resolution order: (1) resume any in_progress task, (2) suggest_next_tasks, " +
309
+ "(3) my_tasks fallback. Pass fresh:true to skip the resume step.",
310
+ inputSchema: {
311
+ type: 'object',
312
+ properties: {
313
+ scope: {
314
+ type: 'object',
315
+ properties: {
316
+ plan_id: { type: 'string' },
317
+ goal_id: { type: 'string' },
318
+ },
319
+ },
320
+ ttl_minutes: { type: 'integer', default: 30 },
321
+ fresh: { type: 'boolean', default: false },
322
+ context_depth: { type: 'integer', enum: [1, 2, 3, 4], default: 2 },
323
+ },
324
+ required: ['scope'],
325
+ },
326
+ };
327
+
328
+ async function claimNextTaskHandler(args, apiClient) {
329
+ const { scope = {}, ttl_minutes = 30, fresh = false, context_depth = 2 } = args;
330
+ const { plan_id, goal_id } = scope;
331
+
332
+ let chosen = null;
333
+ let source = null;
334
+
335
+ // 1. Resume in-progress (unless fresh)
336
+ if (!fresh) {
337
+ try {
338
+ const myTasks = await apiClient.users.getMyTasks({ plan_id });
339
+ const tasks = (myTasks.tasks || myTasks || []).filter((t) => t.status === 'in_progress');
340
+ if (plan_id) tasks.filter((t) => t.plan_id === plan_id);
341
+ if (tasks[0]) {
342
+ chosen = tasks[0];
343
+ source = 'resume_in_progress';
344
+ }
345
+ } catch {}
346
+ }
347
+
348
+ // 2. suggest_next_tasks
349
+ if (!chosen && plan_id) {
350
+ try {
351
+ const params = new URLSearchParams({ plan_id, limit: '1' });
352
+ const r = await apiClient.axiosInstance.get(`/plans/${plan_id}/suggest-next-tasks?${params}`);
353
+ const suggested = (r.data?.tasks || r.data || [])[0];
354
+ if (suggested) {
355
+ chosen = suggested;
356
+ source = 'suggest_next_tasks';
357
+ }
358
+ } catch {}
359
+ }
360
+
361
+ // 3. my_tasks fallback (first not_started)
362
+ if (!chosen) {
363
+ try {
364
+ const myTasks = await apiClient.users.getMyTasks({ plan_id });
365
+ const tasks = (myTasks.tasks || myTasks || []).filter((t) => t.status === 'not_started');
366
+ if (tasks[0]) {
367
+ chosen = tasks[0];
368
+ source = 'my_tasks_fallback';
369
+ }
370
+ } catch {}
371
+ }
372
+
373
+ if (!chosen) {
374
+ return errorResponse('not_found', 'No task available in scope');
375
+ }
376
+
377
+ const taskPlanId = chosen.plan_id || plan_id;
378
+ const taskId = chosen.id;
379
+
380
+ // Claim
381
+ let claim = null;
382
+ try {
383
+ claim = await apiClient.nodes.claimTask(taskPlanId, taskId, 'mcp-agent', ttl_minutes);
384
+ } catch (err) {
385
+ return errorResponse('claim_collision', `Could not claim task ${taskId}: ${err.response?.data?.error || err.message}`);
386
+ }
387
+
388
+ // Load context
389
+ let context = null;
390
+ try {
391
+ const params = new URLSearchParams({
392
+ node_id: taskId,
393
+ depth: String(context_depth),
394
+ log_limit: '10',
395
+ include_research: 'true',
396
+ });
397
+ const ctxResp = await apiClient.axiosInstance.get(`/context/progressive?${params}`);
398
+ context = ctxResp.data;
399
+ } catch (err) {
400
+ // Best-effort: still return claim with the bare task object
401
+ }
402
+
403
+ return formatResponse({
404
+ as_of: asOf(),
405
+ task: context || chosen,
406
+ plan_id: taskPlanId,
407
+ source,
408
+ claim: {
409
+ claimed_at: claim?.claimed_at || asOf(),
410
+ expires_at: claim?.expires_at,
411
+ ttl_minutes,
412
+ },
413
+ next_action_hint: chosen.task_mode === 'implement'
414
+ ? 'Task is implement mode — research and plan outputs are included in context if available'
415
+ : 'Task ready to start — call update_task with status=in_progress when work begins',
416
+ });
417
+ }
418
+
419
+ // ─────────────────────────────────────────────────────────────────────────
420
+ // release_task — explicit handoff.
421
+ // ─────────────────────────────────────────────────────────────────────────
422
+
423
+ const releaseTaskDefinition = {
424
+ name: 'release_task',
425
+ description: 'Release a claimed task without changing status. Use for explicit handoff or abandonment.',
426
+ inputSchema: {
427
+ type: 'object',
428
+ properties: {
429
+ task_id: { type: 'string' },
430
+ plan_id: { type: 'string', description: 'Auto-resolved from task if omitted' },
431
+ message: { type: 'string', description: 'Optional log entry on release' },
432
+ },
433
+ required: ['task_id'],
434
+ },
435
+ };
436
+
437
+ async function releaseTaskHandler(args, apiClient) {
438
+ const { task_id, message } = args;
439
+ let planId = args.plan_id;
440
+ if (!planId) {
441
+ try {
442
+ const node = await apiClient.axiosInstance.get(`/nodes/${task_id}`).then((r) => r.data);
443
+ planId = node.plan_id || node.planId;
444
+ } catch (err) {
445
+ return errorResponse('not_found', `Could not resolve plan_id from task ${task_id}: ${err.message}`);
446
+ }
447
+ }
448
+ try {
449
+ await apiClient.nodes.releaseTask(planId, task_id, 'mcp-agent');
450
+ } catch (err) {
451
+ return errorResponse('upstream_unavailable', `release failed: ${err.message}`);
452
+ }
453
+ let logId = null;
454
+ if (message) {
455
+ try {
456
+ const log = await apiClient.logs.addLog(planId, task_id, { content: message, log_type: 'progress' });
457
+ logId = log?.id || log?.log?.id;
458
+ } catch {}
459
+ }
460
+ return formatResponse({ as_of: asOf(), task_id, plan_id: planId, released: true, log_id: logId });
461
+ }
462
+
463
+ // ─────────────────────────────────────────────────────────────────────────
464
+ // add_learning — knowledge graph write.
465
+ // ─────────────────────────────────────────────────────────────────────────
466
+
467
+ const addLearningDefinition = {
468
+ name: 'add_learning',
469
+ description:
470
+ "Record a knowledge episode. Use after research, on decisions, or when discovering " +
471
+ "important context. Graphiti extracts entities/relationships automatically. " +
472
+ "Surfaces coherence_warnings if the new content contradicts existing facts.",
473
+ inputSchema: {
474
+ type: 'object',
475
+ properties: {
476
+ content: { type: 'string' },
477
+ scope: {
478
+ type: 'object',
479
+ properties: { plan_id: { type: 'string' }, goal_id: { type: 'string' }, node_id: { type: 'string' } },
480
+ },
481
+ entry_type: {
482
+ type: 'string',
483
+ enum: ['fact', 'decision', 'pattern', 'constraint', 'technique', 'learning'],
484
+ default: 'fact',
485
+ },
486
+ source_description: { type: 'string' },
487
+ },
488
+ required: ['content'],
489
+ },
490
+ };
491
+
492
+ async function addLearningHandler(args, apiClient) {
493
+ const { content, scope = {}, entry_type = 'fact', source_description } = args;
494
+ try {
495
+ const result = await apiClient.graphiti.addEpisode({
496
+ content,
497
+ name: content.slice(0, 80),
498
+ source: 'text',
499
+ source_description: source_description || 'BDI add_learning',
500
+ plan_id: scope.plan_id,
501
+ node_id: scope.node_id,
502
+ entity_type: entry_type,
503
+ });
504
+ return formatResponse({
505
+ as_of: asOf(),
506
+ episode_id: result.episode?.uuid || result.uuid || null,
507
+ coherence_warnings: result.coherence_warnings || [],
508
+ message: result.message || 'Knowledge recorded',
509
+ });
510
+ } catch (err) {
511
+ return errorResponse('upstream_unavailable', `add_learning failed: ${err.response?.data?.error || err.message}`);
512
+ }
513
+ }
514
+
515
+ module.exports = {
516
+ definitions: [
517
+ queueDecisionDefinition,
518
+ resolveDecisionDefinition,
519
+ updateTaskDefinition,
520
+ claimNextTaskDefinition,
521
+ releaseTaskDefinition,
522
+ addLearningDefinition,
523
+ ],
524
+ handlers: {
525
+ queue_decision: queueDecisionHandler,
526
+ resolve_decision: resolveDecisionHandler,
527
+ update_task: updateTaskHandler,
528
+ claim_next_task: claimNextTaskHandler,
529
+ release_task: releaseTaskHandler,
530
+ add_learning: addLearningHandler,
531
+ },
532
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * BDI utility — onboarding.
3
+ */
4
+
5
+ const { asOf, formatResponse } = require('./_shared');
6
+
7
+ const getStartedDefinition = {
8
+ name: 'get_started',
9
+ description:
10
+ "Onboarding for new agents. Returns the BDI tool surface map and recommended " +
11
+ "workflows: mission control loop (Cowork), single-task session (Code/CLI), " +
12
+ "multi-agent claiming (OpenClaw).",
13
+ inputSchema: {
14
+ type: 'object',
15
+ properties: {
16
+ user_role: { type: 'string', enum: ['agent', 'human'], default: 'agent' },
17
+ },
18
+ },
19
+ };
20
+
21
+ async function getStartedHandler(args) {
22
+ return formatResponse({
23
+ as_of: asOf(),
24
+ overview:
25
+ "AgentPlanner exposes a BDI-aligned MCP surface. Tools are grouped by " +
26
+ "Beliefs (state queries), Desires (goals), and Intentions (committed actions). " +
27
+ "Each tool answers one whole agentic question and returns an `as_of` timestamp.",
28
+ tools_by_namespace: {
29
+ beliefs: ['briefing', 'task_context', 'goal_state', 'recall_knowledge', 'search', 'plan_analysis'],
30
+ desires: ['list_goals', 'update_goal'],
31
+ intentions: ['claim_next_task', 'update_task', 'release_task', 'queue_decision', 'resolve_decision', 'add_learning'],
32
+ },
33
+ recommended_workflows: [
34
+ {
35
+ name: 'Mission control loop (Cowork autopilot or scheduled task)',
36
+ steps: [
37
+ 'briefing(scope="mission_control") — single read for full state',
38
+ 'For each at_risk goal: goal_state(goal_id)',
39
+ 'If action is reversible: do it via update_task or update_goal',
40
+ 'If action needs human approval: queue_decision',
41
+ 'Always: add_learning to record what you did and why',
42
+ ],
43
+ },
44
+ {
45
+ name: 'Single coding session (Claude Code, ap CLI)',
46
+ steps: [
47
+ 'claim_next_task(scope={plan_id}) — pick + claim + load context',
48
+ 'update_task(task_id, status="in_progress") when work starts',
49
+ 'update_task(task_id, status="completed", log_message=..., add_learning=...) when done',
50
+ ],
51
+ },
52
+ {
53
+ name: 'Multi-agent server (OpenClaw)',
54
+ steps: [
55
+ 'claim_next_task with explicit ttl_minutes',
56
+ 'Periodic task_context refresh during long work',
57
+ 'release_task on handoff',
58
+ ],
59
+ },
60
+ ],
61
+ key_principles: [
62
+ 'Tools are intent-shaped, not CRUD-shaped',
63
+ 'Reads are bundled to minimize round trips',
64
+ 'Writes are atomic where possible (update_task does status+log+release)',
65
+ 'as_of on every response — use for cache freshness',
66
+ ],
67
+ });
68
+ }
69
+
70
+ module.exports = {
71
+ definitions: [getStartedDefinition],
72
+ handlers: { get_started: getStartedHandler },
73
+ };