@ziggs-ai/agent-sdk 0.1.3

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.
Files changed (40) hide show
  1. package/README.md +82 -0
  2. package/package.json +26 -0
  3. package/src/ConnectionPool.js +133 -0
  4. package/src/adapters/OpenAIAdapter.js +73 -0
  5. package/src/adapters/index.js +1 -0
  6. package/src/agent/Agent.js +121 -0
  7. package/src/agent/EventQueue.js +68 -0
  8. package/src/agent/OutboxBuffer.js +62 -0
  9. package/src/cognition/PromptBuilder.js +312 -0
  10. package/src/cognition/resolveActionTool.js +12 -0
  11. package/src/cognition/runTurn.js +578 -0
  12. package/src/context/applyEffects.js +133 -0
  13. package/src/context/batch.js +25 -0
  14. package/src/context/classifyEnvelope.js +82 -0
  15. package/src/context/routingLabels.js +54 -0
  16. package/src/createHealthServer.js +28 -0
  17. package/src/formatters/HistoryFormatter.js +257 -0
  18. package/src/formatters/TaskFormatter.js +180 -0
  19. package/src/formatters/index.js +9 -0
  20. package/src/index.js +76 -0
  21. package/src/ingress/normalizeIncoming.js +70 -0
  22. package/src/runLauncher.js +159 -0
  23. package/src/shared/ids.js +7 -0
  24. package/src/shared/types.js +86 -0
  25. package/src/tasks/TaskService.js +247 -0
  26. package/src/tasks/index.js +9 -0
  27. package/src/tasks/taskCore.js +229 -0
  28. package/src/tasks/taskProtocolRegistry.js +22 -0
  29. package/src/tasks/taskProtocolRunner.js +107 -0
  30. package/src/tasks/taskProtocolTools.js +87 -0
  31. package/src/tools/ToolManager.js +79 -0
  32. package/src/tools/ToolProvider.js +29 -0
  33. package/src/tools/defineTool.js +82 -0
  34. package/src/tools/index.js +11 -0
  35. package/src/utils/jsonExtractor.js +139 -0
  36. package/src/workflow/AgentMachine.js +250 -0
  37. package/src/workflow/WorkflowRuntime.js +63 -0
  38. package/src/workflow/dsl.js +287 -0
  39. package/src/workflow/motifs.js +435 -0
  40. package/src/ziggs/runtime.js +192 -0
@@ -0,0 +1,247 @@
1
+ import {
2
+ createTask as apiCreateTask,
3
+ updateTaskState,
4
+ updatePlanStep as apiUpdatePlanStep,
5
+ proposeToDoWork as apiProposeToDoWork,
6
+ delegateToAgent as apiDelegateToAgent,
7
+ assignTask as apiAssignTask,
8
+ respondToProposal as apiRespondToProposal,
9
+ claimLedgerTask as apiClaimLedgerTask
10
+ } from '@ziggs-ai/api-client';
11
+
12
+ /** @typedef {import('./taskCore.js').Task} Task */
13
+ /** @typedef {import('./taskCore.js').TaskWithFlags} TaskWithFlags */
14
+ /** @typedef {import('./taskCore.js').TaskContract} TaskContract */
15
+ /** @typedef {import('./taskCore.js').TaskPerspective} TaskPerspective */
16
+
17
+ function safeJsonStringify(value) {
18
+ if (value === null || value === undefined) return value;
19
+ if (typeof value === 'string') return value;
20
+ if (typeof value !== 'object') return String(value);
21
+
22
+ const seen = new WeakSet();
23
+ try {
24
+ return JSON.stringify(
25
+ value,
26
+ (key, val) => {
27
+ if (typeof val === 'object' && val !== null) {
28
+ if (seen.has(val)) return '[Circular]';
29
+ seen.add(val);
30
+ }
31
+ return val;
32
+ },
33
+ 2
34
+ );
35
+ } catch {
36
+ return String(value);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * TaskService — thin wrapper over API client.
42
+ *
43
+ * Methods accept a single `payload` object that is forwarded to the API client
44
+ * with minimal mutation. The payload shape matches the backend DTOs directly
45
+ * so callers (taskProtocolRunner) can pass the LLM-produced args through.
46
+ *
47
+ * All methods return a {@link Task} or {@link TaskWithFlags} — the same shape
48
+ * the backend persists in MongoDB.
49
+ */
50
+ export class TaskService {
51
+ constructor(operatorKey, agentId) {
52
+ if (!operatorKey) {
53
+ throw new Error('TaskService: operatorKey is required');
54
+ }
55
+ if (!agentId) {
56
+ throw new Error('TaskService: agentId is required (operator-token impersonation)');
57
+ }
58
+ this.creds = { operatorKey, agentId };
59
+ }
60
+
61
+ /**
62
+ * Propose work to a user or agent for approval.
63
+ * Creates a task in state='proposal' with proposal.status='pending'.
64
+ *
65
+ * @param {Object} payload
66
+ * @param {string} payload.description
67
+ * @param {string} payload.proposedTo - userId or agentId to approve
68
+ * @param {string} payload.chatId
69
+ * @param {TaskContract} [payload.contract]
70
+ * @param {TaskPerspective} [payload.perspective]
71
+ * @param {string} [payload.parentTaskId]
72
+ * @param {string} [payload.payerId]
73
+ * @returns {Promise<TaskWithFlags>}
74
+ */
75
+ async proposeToDoWork(payload) {
76
+ try {
77
+ const result = await apiProposeToDoWork(payload, this.creds);
78
+ console.log(`✅ Proposal created (you do the work): ${result?.taskId || 'unknown'}`);
79
+ return result;
80
+ } catch (error) {
81
+ console.error(`❌ Proposal creation failed: ${error.message}`);
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Assign task directly (no proposal step). Requires parentTaskId (user approval chain).
88
+ * Creates a task in state='active' immediately.
89
+ *
90
+ * @param {Object} payload
91
+ * @param {string} payload.description
92
+ * @param {string} payload.executorId - agentId who will execute
93
+ * @param {string} payload.chatId
94
+ * @param {string} payload.parentTaskId - required (proves user approval)
95
+ * @param {TaskContract} [payload.contract]
96
+ * @param {TaskPerspective} [payload.perspective]
97
+ * @param {string} [payload.payerId]
98
+ * @returns {Promise<TaskWithFlags>}
99
+ */
100
+ async assignTask(payload) {
101
+ try {
102
+ const result = await apiAssignTask(payload, this.creds);
103
+ console.log(`⚡ Task directly assigned (no proposal): ${result?.taskId || 'unknown'}`);
104
+ return result;
105
+ } catch (error) {
106
+ console.error(`❌ Task assignment failed: ${error.message}`);
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Delegate task to another agent (creates proposal to executor). Requires parentTaskId.
113
+ * Creates a task in state='proposal' where proposedTo=executorId.
114
+ *
115
+ * @param {Object} payload
116
+ * @param {string} payload.description
117
+ * @param {string} payload.executorId - agentId to delegate to
118
+ * @param {string} payload.chatId
119
+ * @param {string} payload.parentTaskId - required (proves user approval)
120
+ * @param {TaskContract} [payload.contract]
121
+ * @param {TaskPerspective} [payload.perspective]
122
+ * @param {string} [payload.payerId]
123
+ * @returns {Promise<TaskWithFlags>}
124
+ */
125
+ async delegateToAgent(payload) {
126
+ try {
127
+ const result = await apiDelegateToAgent(payload, this.creds);
128
+ console.log(`✅ Proposal created (delegated): ${result?.taskId || 'unknown'}`);
129
+ return result;
130
+ } catch (error) {
131
+ console.error(`❌ Proposal creation failed: ${error.message}`);
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Create a standalone task (no proposal workflow). State starts as 'active'.
138
+ *
139
+ * @param {Object} taskData
140
+ * @param {string} taskData.description
141
+ * @param {string} [taskData.executorId]
142
+ * @param {string} [taskData.payerId]
143
+ * @param {string} [taskData.parentTaskId]
144
+ * @param {TaskContract} [taskData.contract]
145
+ * @param {TaskPerspective} [taskData.perspective]
146
+ * @param {{steps?: {stepId: string, description: string, order: number}[]}} [taskData.plan]
147
+ * @param {string} chatId
148
+ * @returns {Promise<Task>}
149
+ */
150
+ async createTask(taskData, chatId) {
151
+ try {
152
+ const result = await apiCreateTask({
153
+ ...taskData,
154
+ chatId
155
+ }, this.creds);
156
+
157
+ console.log(`✅ Task created: ${result?.taskId || 'unknown'}`);
158
+ return result;
159
+ } catch (error) {
160
+ console.error(`❌ Task creation failed: ${error.message}`);
161
+ throw error;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Respond to a proposal — approve or reject.
167
+ * approve: transitions state proposal→active, sets proposal.status='approved'
168
+ * reject: transitions state proposal→cancelled, sets proposal.status='rejected'
169
+ *
170
+ * @param {string} taskId
171
+ * @param {'approve'|'reject'} action
172
+ * @param {string} [chatId]
173
+ * @returns {Promise<TaskWithFlags>}
174
+ */
175
+ async respondToProposal(taskId, action, chatId) {
176
+ try {
177
+ const result = await apiRespondToProposal(taskId, action, this.creds);
178
+ console.log(`✅ Proposal ${action}d: ${taskId}`);
179
+ return result;
180
+ } catch (error) {
181
+ console.error(`❌ Proposal response failed: ${error.message}`);
182
+ throw error;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Update a plan step within a task.
188
+ *
189
+ * @param {string} taskId
190
+ * @param {string} stepId
191
+ * @param {'pending'|'in_progress'|'completed'|'skipped'} status
192
+ * @param {*} [result]
193
+ * @returns {Promise<Task|null>}
194
+ */
195
+ async updatePlanStep(taskId, stepId, status, result) {
196
+ try {
197
+ const updated = await apiUpdatePlanStep(taskId, stepId, status, result, this.creds);
198
+ console.log(`✅ Plan step updated: ${taskId} / ${stepId} -> ${status}`);
199
+ return updated;
200
+ } catch (error) {
201
+ console.error(`❌ Plan step update failed: ${error.message}`);
202
+ throw error;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Transition a task's state. Only valid transitions are accepted by the backend:
208
+ * proposal → active | cancelled
209
+ * active → completed | failed | cancelled
210
+ *
211
+ * @param {string} taskId
212
+ * @param {'completed'|'failed'|'cancelled'} status - target state
213
+ * @param {*} [result] - task result (stored in task.result)
214
+ * @param {string} [chatId]
215
+ * @returns {Promise<Task|null>}
216
+ */
217
+ async updateTask(taskId, status, result, chatId) {
218
+ try {
219
+ const data = result ? { result: safeJsonStringify(result) } : {};
220
+ const updated = await updateTaskState(taskId, status, data, this.creds);
221
+
222
+ console.log(`✅ Task updated: ${taskId} -> ${status}`);
223
+ return updated;
224
+ } catch (error) {
225
+ console.error(`❌ Task update failed: ${error.message}`);
226
+ throw error;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Claim an open ledger task (proposal.proposedTo='everyone').
232
+ * Atomically sets executorId to the claiming agent and transitions to state='active'.
233
+ *
234
+ * @param {string} taskId
235
+ * @returns {Promise<TaskWithFlags>}
236
+ */
237
+ async claimLedgerTask(taskId) {
238
+ try {
239
+ const task = await apiClaimLedgerTask(taskId, this.creds);
240
+ console.log(`✅ [ledger] Claimed task ${taskId}`);
241
+ return task;
242
+ } catch (error) {
243
+ console.warn(`⚠️ [ledger] Claim failed for ${taskId}: ${error.message}`);
244
+ throw error;
245
+ }
246
+ }
247
+ }
@@ -0,0 +1,9 @@
1
+ export { TaskService } from './TaskService.js';
2
+ export { TASK_PROTOCOL_TOOLS } from './taskProtocolTools.js';
3
+ export {
4
+ TASK_PROTOCOL_TOOL_NAMES,
5
+ TASK_PROTOCOL_TOOL_TO_OPERATION,
6
+ mapTaskProtocolToolToOperation,
7
+ isTaskProtocolToolName,
8
+ } from './taskProtocolRegistry.js';
9
+ export { executeTaskPayload } from './taskProtocolRunner.js';
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Canonical Task definitions — states, state machine, shapes, and helpers.
3
+ *
4
+ * This is the single source of truth for what a Task looks like in the Ziggs system.
5
+ * Every layer (DB, API, SDK, frontend) must conform to these definitions.
6
+ *
7
+ * ⚠️ SYNC: Keep in sync with backend/src/tasks/task-core.ts
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // States
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export const TaskState = {
15
+ PROPOSAL: 'proposal',
16
+ ACTIVE: 'active',
17
+ COMPLETED: 'completed',
18
+ FAILED: 'failed',
19
+ CANCELLED: 'cancelled',
20
+ };
21
+
22
+ export const TASK_STATES = ['proposal', 'active', 'completed', 'failed', 'cancelled'];
23
+
24
+ export const TERMINAL_STATES = ['completed', 'failed', 'cancelled'];
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // State Machine
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export const VALID_TRANSITIONS = {
31
+ proposal: ['active', 'cancelled'],
32
+ active: ['completed', 'failed', 'cancelled'],
33
+ };
34
+
35
+ export function isTerminal(state) {
36
+ return TERMINAL_STATES.includes(state);
37
+ }
38
+
39
+ export function canTransitionTo(from, to) {
40
+ if (isTerminal(from)) return false;
41
+ const allowed = VALID_TRANSITIONS[from];
42
+ if (!allowed) return false;
43
+ return allowed.includes(to);
44
+ }
45
+
46
+ /**
47
+ * Normalize legacy state aliases.
48
+ * 'done' → 'completed', 'error' → 'failed'.
49
+ * Passthrough for canonical states.
50
+ */
51
+ export function normalizeState(state) {
52
+ if (state === 'done') return TaskState.COMPLETED;
53
+ if (state === 'error') return TaskState.FAILED;
54
+ return state;
55
+ }
56
+
57
+ /**
58
+ * Returns true if `state` is a valid canonical TaskState value.
59
+ */
60
+ export function isValidState(state) {
61
+ return TASK_STATES.includes(state);
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Proposal
66
+ // ---------------------------------------------------------------------------
67
+
68
+ export const ProposalStatus = {
69
+ PENDING: 'pending',
70
+ APPROVED: 'approved',
71
+ REJECTED: 'rejected',
72
+ };
73
+
74
+ export const OPEN_PROPOSAL_TARGET = 'everyone';
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Plan Step
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export const PlanStepStatus = {
81
+ PENDING: 'pending',
82
+ IN_PROGRESS: 'in_progress',
83
+ COMPLETED: 'completed',
84
+ SKIPPED: 'skipped',
85
+ };
86
+
87
+ export const PLAN_STEP_STATUSES = ['pending', 'in_progress', 'completed', 'skipped'];
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // History
91
+ // ---------------------------------------------------------------------------
92
+
93
+ export const HistoryChangeType = {
94
+ CREATED: 'created',
95
+ STATE_CHANGED: 'state_changed',
96
+ PROCESSING_CHANGED: 'processing_changed',
97
+ PROPOSAL_RESPONDED: 'proposal_responded',
98
+ PLAN_STEP_CHANGED: 'plan_step_changed',
99
+ };
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Contract lifecycle
103
+ // ---------------------------------------------------------------------------
104
+
105
+ export const ContractLifecycle = {
106
+ OPEN: 'open',
107
+ TIME_BOUND: 'time-bound',
108
+ COUNT_BOUND: 'count-bound',
109
+ };
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Task shape — JSDoc mirrors backend ITask interface exactly.
113
+ // These are not runtime constructs, just documentation so SDK devs
114
+ // see the same field names the backend uses.
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * @typedef {Object} TaskContract
119
+ * @property {'open'|'time-bound'|'count-bound'} lifecycle
120
+ * @property {string|null} expiresAt - ISO date string or null
121
+ * @property {number|null} maxExecutions
122
+ * @property {string} description
123
+ * @property {string} [specialistAgentId] - single specialist
124
+ * @property {string[]} [specialistAgentIds] - multi-specialist orchestration
125
+ */
126
+
127
+ /**
128
+ * @typedef {Object} TaskProposal
129
+ * @property {string|null} proposedTo - userId/agentId or 'everyone' for ledger
130
+ * @property {'pending'|'approved'|'rejected'|null} status
131
+ * @property {string|null} respondedBy
132
+ * @property {string|null} proposedAt - ISO date string
133
+ * @property {string|null} respondedAt - ISO date string
134
+ */
135
+
136
+ /**
137
+ * @typedef {Object} TaskPlanStep
138
+ * @property {string} stepId
139
+ * @property {string} description
140
+ * @property {number} order
141
+ * @property {'pending'|'in_progress'|'completed'|'skipped'} status
142
+ * @property {*} result
143
+ */
144
+
145
+ /**
146
+ * @typedef {Object} TaskPlan
147
+ * @property {TaskPlanStep[]} steps
148
+ * @property {string} createdAt - ISO date string
149
+ * @property {string} updatedAt - ISO date string
150
+ */
151
+
152
+ /**
153
+ * @typedef {Object} TaskHistoryEntry
154
+ * @property {'created'|'state_changed'|'processing_changed'|'proposal_responded'|'plan_step_changed'} changeType
155
+ * @property {*} previousState
156
+ * @property {*} newState
157
+ * @property {string} timestamp - ISO date string
158
+ * @property {Object} metadata
159
+ */
160
+
161
+ /**
162
+ * @typedef {Object} TaskPerspective
163
+ * @property {string|null} ownerChatId
164
+ */
165
+
166
+ /**
167
+ * The canonical Task object returned by all API endpoints.
168
+ * Mirrors backend ITask exactly — same fields, same names, same nesting.
169
+ *
170
+ * @typedef {Object} Task
171
+ * @property {string} taskId
172
+ * @property {string} description
173
+ * @property {string|null} chatId
174
+ * @property {string|null} agentId - who created/proposed the task
175
+ * @property {string|null} executorId - who executes the task
176
+ * @property {string|null} payerId - who pays/approves
177
+ * @property {TaskContract} contract
178
+ * @property {TaskPerspective} perspective
179
+ * @property {'proposal'|'active'|'completed'|'failed'|'cancelled'} state
180
+ * @property {TaskProposal} proposal
181
+ * @property {boolean} processing - atomic lock for concurrent execution
182
+ * @property {boolean} deleted - soft-delete flag
183
+ * @property {*} result
184
+ * @property {string|null} errorMessage
185
+ * @property {string} createdAt - ISO date string
186
+ * @property {string} updatedAt - ISO date string
187
+ * @property {string|null} parentTaskId
188
+ * @property {string|null} rootTaskId
189
+ * @property {TaskPlan} plan
190
+ * @property {TaskHistoryEntry[]} history
191
+ */
192
+
193
+ /**
194
+ * Task with "isYou" perspective flags (added by backend when requesting agent is known).
195
+ * Mirrors backend ITaskWithFlags.
196
+ *
197
+ * @typedef {Task & {
198
+ * agentIdIsYou?: boolean,
199
+ * executorIdIsYou?: boolean,
200
+ * payerIdIsYou?: boolean,
201
+ * proposedToIsYou?: boolean,
202
+ * }} TaskWithFlags
203
+ */
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Defaults — use these when constructing task payloads to match backend defaults.
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /** Default contract shape (matches Mongoose schema defaults). */
210
+ export const DEFAULT_CONTRACT = {
211
+ lifecycle: ContractLifecycle.OPEN,
212
+ expiresAt: null,
213
+ maxExecutions: null,
214
+ description: '',
215
+ };
216
+
217
+ /** Default perspective shape (matches Mongoose schema defaults). */
218
+ export const DEFAULT_PERSPECTIVE = {
219
+ ownerChatId: null,
220
+ };
221
+
222
+ /** Default proposal shape (matches Mongoose schema defaults). */
223
+ export const DEFAULT_PROPOSAL = {
224
+ proposedTo: null,
225
+ status: null,
226
+ respondedBy: null,
227
+ proposedAt: null,
228
+ respondedAt: null,
229
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Maps built-in task protocol tool names (task_*) to task service operation strings.
3
+ */
4
+
5
+ export const TASK_PROTOCOL_TOOL_TO_OPERATION = Object.freeze({
6
+ task_make_task: 'make-task',
7
+ task_make_sub_tasks: 'make-sub-tasks',
8
+ task_update_task: 'update-task',
9
+ task_respond_proposal: 'respond-proposal',
10
+ task_update_plan_step: 'update-plan-step',
11
+ });
12
+
13
+ export const TASK_PROTOCOL_TOOL_NAMES = Object.freeze(Object.keys(TASK_PROTOCOL_TOOL_TO_OPERATION));
14
+
15
+ export function mapTaskProtocolToolToOperation(toolName) {
16
+ if (!toolName || typeof toolName !== 'string') return null;
17
+ return TASK_PROTOCOL_TOOL_TO_OPERATION[toolName] || null;
18
+ }
19
+
20
+ export function isTaskProtocolToolName(toolName) {
21
+ return mapTaskProtocolToolToOperation(toolName) != null;
22
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Shared task-service execution used by the workflow engine and task protocol tools.
3
+ *
4
+ * The LLM produces a task object (e.g. { description, proposedTo, contract, ... }).
5
+ * This module routes it to the right TaskService method with minimal mutation —
6
+ * the original payload flows through to the API client almost untouched.
7
+ */
8
+
9
+ import { normalizeState } from './taskCore.js';
10
+
11
+ function specialistIdFromTurnScratch(toolCtx) {
12
+ const hit = toolCtx?.turnScratch?.agentSearchLastResult;
13
+ if (!hit || hit.operation !== 'search' || hit.success === false) return null;
14
+ const outer = hit.data;
15
+ if (!outer || typeof outer !== 'object') return null;
16
+ const inner = outer.data && typeof outer.data === 'object' ? outer.data : outer;
17
+ const list = inner.agents;
18
+ if (!Array.isArray(list) || list.length === 0) return null;
19
+ const first = list[0];
20
+ const id = first?.agentId ?? first?.id;
21
+ return typeof id === 'string' && id.length > 0 ? id : null;
22
+ }
23
+
24
+ export async function executeTaskPayload(taskService, task, chatId, agents = [], toolCtx = {}) {
25
+ if (!task?.operation) return null;
26
+ const { operation } = task;
27
+
28
+ switch (operation) {
29
+ case 'make-task':
30
+ return await createSingleTask(taskService, task, chatId, agents, toolCtx);
31
+ case 'make-sub-tasks':
32
+ return await createTaskWithSubtasks(taskService, task, chatId);
33
+ case 'update-task':
34
+ if (!task.taskId || !task.status) return null;
35
+ return await taskService.updateTask(task.taskId, normalizeState(task.status), task.result, chatId);
36
+ case 'respond-proposal':
37
+ if (!task.taskId || !task.action) return null;
38
+ return await taskService.respondToProposal(task.taskId, task.action, chatId);
39
+ case 'update-plan-step':
40
+ if (!task.taskId || !task.stepId || !task.status) return null;
41
+ return await taskService.updatePlanStep(task.taskId, task.stepId, task.status, task.result);
42
+ default:
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export async function createSingleTask(taskService, task, chatId, agents = [], toolCtx = {}) {
48
+ if (!task.description) return null;
49
+
50
+ // Build the payload from the LLM-produced fields — pass through, don't restructure.
51
+ // Only `operation` is SDK-internal and gets stripped.
52
+ const { operation: _op, ...fields } = task;
53
+ const inferred = specialistIdFromTurnScratch(toolCtx);
54
+ const mergedContract = { ...(task.contract || {}) };
55
+ const hasMulti =
56
+ Array.isArray(mergedContract.specialistAgentIds) &&
57
+ mergedContract.specialistAgentIds.length > 0;
58
+ if (!mergedContract.specialistAgentId && !hasMulti && inferred) {
59
+ mergedContract.specialistAgentId = inferred;
60
+ }
61
+ const payload = { ...fields, chatId, contract: mergedContract };
62
+
63
+ const parentTaskId = task.parentTaskId || null;
64
+
65
+ if (task.proposedTo && !task.executorId) {
66
+ const target = agents.find(a => a.agentId === task.proposedTo);
67
+ const caps = target?.capabilities || {};
68
+ // Users are never in `agents[]` as agentId — treating !target as "assign" wrongly called assignTask
69
+ // (requires parentTaskId) and broke user proposal cards (proposeToDoWork).
70
+ // Direct assign only after an approved chain: parentTaskId + (off-context executor OR tAcceptDecline false).
71
+ const useDirectAssign =
72
+ parentTaskId && (!target || caps.tAcceptDecline === false);
73
+ if (useDirectAssign) {
74
+ return await taskService.assignTask({ ...payload, executorId: task.proposedTo });
75
+ }
76
+ return await taskService.proposeToDoWork(payload);
77
+ }
78
+
79
+ if (task.executorId) {
80
+ const target = agents.find(a => a.agentId === task.executorId);
81
+ const caps = target?.capabilities || {};
82
+ const useDirectAssign =
83
+ parentTaskId && (!target || caps.tAcceptDecline === false);
84
+ if (useDirectAssign) {
85
+ return await taskService.assignTask(payload);
86
+ }
87
+ return await taskService.delegateToAgent(payload);
88
+ }
89
+
90
+ return await taskService.createTask(task, chatId);
91
+ }
92
+
93
+ export async function createTaskWithSubtasks(taskService, task, chatId) {
94
+ if (!task.description || !Array.isArray(task.subtasks) || !task.subtasks.length) return null;
95
+ const parent = await taskService.createTask(
96
+ { description: task.description, contract: task.contract || {}, perspective: task.perspective || {}, parentTaskId: task.parentTaskId || null },
97
+ chatId
98
+ );
99
+ if (!parent?.taskId) return null;
100
+ const results = await Promise.all(
101
+ task.subtasks.map(s => {
102
+ const desc = typeof s === 'string' ? s : (s.description || '');
103
+ return taskService.createTask({ description: desc, parentTaskId: parent.taskId }, chatId);
104
+ })
105
+ );
106
+ return { ...parent, subtasks: results.filter(r => r?.taskId) };
107
+ }