@yeaft/webchat-agent 0.1.477 → 0.1.479
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/engine.js +30 -4
- package/unify/session.js +6 -0
- package/unify/threads/store.js +181 -0
- package/unify/tools/index.js +17 -0
- package/unify/tools/spawn-task-tools.js +125 -0
- package/unify/tools/thread-tools.js +183 -0
- package/unify/web-bridge.js +5 -1
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -24,6 +24,7 @@ import { recall } from './memory/recall.js';
|
|
|
24
24
|
import { shouldConsolidate, consolidate } from './memory/consolidate.js';
|
|
25
25
|
import { buildMemoryInjection } from './memory/layout.js';
|
|
26
26
|
import { runStopHooks } from './stop-hooks.js';
|
|
27
|
+
import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
|
|
27
28
|
|
|
28
29
|
/** Maximum number of turns before the engine stops to prevent infinite loops. */
|
|
29
30
|
const MAX_TURNS = 25;
|
|
@@ -269,11 +270,22 @@ export class Engine {
|
|
|
269
270
|
if (!this.#conversationStore) return;
|
|
270
271
|
if (this.#config._readOnly) return;
|
|
271
272
|
|
|
273
|
+
// task-299 Phase 1: tag persisted messages with the current thread.
|
|
274
|
+
// getThreadStore() lazily seeds a default 'main' thread if not yet init'd.
|
|
275
|
+
let threadId = MAIN_THREAD_ID;
|
|
276
|
+
try {
|
|
277
|
+
threadId = getThreadStore().currentId || MAIN_THREAD_ID;
|
|
278
|
+
} catch {
|
|
279
|
+
// Defensive: any store failure falls back to 'main' so persistence
|
|
280
|
+
// never breaks because of thread bookkeeping.
|
|
281
|
+
}
|
|
282
|
+
|
|
272
283
|
// Persist user message
|
|
273
284
|
this.#conversationStore.append({
|
|
274
285
|
role: 'user',
|
|
275
286
|
content: userContent,
|
|
276
287
|
mode,
|
|
288
|
+
threadId,
|
|
277
289
|
});
|
|
278
290
|
|
|
279
291
|
// Persist assistant message
|
|
@@ -282,6 +294,7 @@ export class Engine {
|
|
|
282
294
|
content: assistantContent,
|
|
283
295
|
mode,
|
|
284
296
|
model: this.#config.model,
|
|
297
|
+
threadId,
|
|
285
298
|
};
|
|
286
299
|
if (toolCalls && toolCalls.length > 0) {
|
|
287
300
|
assistantMsg.toolCalls = toolCalls;
|
|
@@ -601,21 +614,21 @@ export class Engine {
|
|
|
601
614
|
if (!hasTool) {
|
|
602
615
|
output = `Error: unknown tool "${tc.name}"`;
|
|
603
616
|
isError = true;
|
|
604
|
-
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true };
|
|
617
|
+
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, threadId: this.currentThreadId };
|
|
605
618
|
} else {
|
|
606
619
|
try {
|
|
607
|
-
yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input };
|
|
620
|
+
yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input, threadId: this.currentThreadId };
|
|
608
621
|
if (this.#toolRegistry) {
|
|
609
622
|
output = await this.#toolRegistry.execute(tc.name, tc.input, toolCtx);
|
|
610
623
|
} else {
|
|
611
624
|
const tool = this.#tools.get(tc.name);
|
|
612
625
|
output = await tool.execute(tc.input, { signal });
|
|
613
626
|
}
|
|
614
|
-
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false };
|
|
627
|
+
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false, threadId: this.currentThreadId };
|
|
615
628
|
} catch (err) {
|
|
616
629
|
output = `Error: ${err.message}`;
|
|
617
630
|
isError = true;
|
|
618
|
-
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true };
|
|
631
|
+
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, threadId: this.currentThreadId };
|
|
619
632
|
}
|
|
620
633
|
}
|
|
621
634
|
|
|
@@ -687,6 +700,19 @@ export class Engine {
|
|
|
687
700
|
/** @returns {import('./mcp.js').MCPManager|null} */
|
|
688
701
|
get mcpManager() { return this.#mcpManager; }
|
|
689
702
|
|
|
703
|
+
/**
|
|
704
|
+
* task-299 Phase 1: the engine's current thread marker.
|
|
705
|
+
* Defaults to 'main' if the thread store is unreachable for any reason.
|
|
706
|
+
* @returns {string}
|
|
707
|
+
*/
|
|
708
|
+
get currentThreadId() {
|
|
709
|
+
try {
|
|
710
|
+
return getThreadStore().currentId || MAIN_THREAD_ID;
|
|
711
|
+
} catch {
|
|
712
|
+
return MAIN_THREAD_ID;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
690
716
|
/** @returns {string|null} */
|
|
691
717
|
get yeaftDir() { return this.#yeaftDir; }
|
|
692
718
|
|
package/unify/session.js
CHANGED
|
@@ -23,6 +23,7 @@ import { SkillManager, createSkillManager } from './skills.js';
|
|
|
23
23
|
import { MCPManager } from './mcp.js';
|
|
24
24
|
import { createFullRegistry } from './tools/index.js';
|
|
25
25
|
import { initTaskStore } from './tools/task-tools.js';
|
|
26
|
+
import { initThreadStore } from './threads/store.js';
|
|
26
27
|
import { Engine } from './engine.js';
|
|
27
28
|
import { join } from 'path';
|
|
28
29
|
|
|
@@ -121,6 +122,11 @@ export async function loadSession(options = {}) {
|
|
|
121
122
|
// ─── 5a. Initialize task store ─────────────────────────
|
|
122
123
|
initTaskStore(yeaftDir, { readOnly: config._readOnly || false });
|
|
123
124
|
|
|
125
|
+
// ─── 5b. Initialize thread store (task-299 Phase 1) ────
|
|
126
|
+
// In-memory only for Phase 1; replaced by a file-backed store
|
|
127
|
+
// when task-298's data layer merges.
|
|
128
|
+
initThreadStore();
|
|
129
|
+
|
|
124
130
|
// ─── 6. Load skills ────────────────────────────────────
|
|
125
131
|
let skillManager;
|
|
126
132
|
if (skipSkills) {
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.js — In-memory ThreadStore for Yeaft Unify (Phase 1 mock).
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 intentionally keeps the implementation in-memory only: it lets the
|
|
5
|
+
* thread/task spawn tools ship and be tested before task-298's real
|
|
6
|
+
* filesystem layer is merged. When task-298 merges, this module will be
|
|
7
|
+
* replaced (or promoted to a shim) by a file-backed store with the same API.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities (Phase 1):
|
|
10
|
+
* - Maintain a map of threadId → thread metadata.
|
|
11
|
+
* - Track a "currentThreadId" marker for the engine.
|
|
12
|
+
* - Maintain attachments from threadId → taskId.
|
|
13
|
+
*
|
|
14
|
+
* A single "main" thread is created on construction so that pre-spawn
|
|
15
|
+
* messages have a valid threadId to carry.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomUUID } from 'crypto';
|
|
19
|
+
|
|
20
|
+
/** Default / root thread id — every fresh ThreadStore has one. */
|
|
21
|
+
export const MAIN_THREAD_ID = 'main';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} Thread
|
|
25
|
+
* @property {string} id
|
|
26
|
+
* @property {string} name
|
|
27
|
+
* @property {string} [goal]
|
|
28
|
+
* @property {string|null} parentThreadId
|
|
29
|
+
* @property {number} createdAt
|
|
30
|
+
* @property {number} updatedAt
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export class ThreadStore {
|
|
34
|
+
/** @type {Map<string, Thread>} */
|
|
35
|
+
#threads;
|
|
36
|
+
|
|
37
|
+
/** @type {string} */
|
|
38
|
+
#currentId;
|
|
39
|
+
|
|
40
|
+
/** @type {Map<string, string>} threadId → taskId */
|
|
41
|
+
#attachments;
|
|
42
|
+
|
|
43
|
+
constructor() {
|
|
44
|
+
this.#threads = new Map();
|
|
45
|
+
this.#attachments = new Map();
|
|
46
|
+
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const main = {
|
|
49
|
+
id: MAIN_THREAD_ID,
|
|
50
|
+
name: 'main',
|
|
51
|
+
goal: '',
|
|
52
|
+
parentThreadId: null,
|
|
53
|
+
createdAt: now,
|
|
54
|
+
updatedAt: now,
|
|
55
|
+
};
|
|
56
|
+
this.#threads.set(main.id, main);
|
|
57
|
+
this.#currentId = main.id;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Get current thread id (defaults to 'main'). */
|
|
61
|
+
get currentId() {
|
|
62
|
+
return this.#currentId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Total thread count (including 'main'). */
|
|
66
|
+
get size() {
|
|
67
|
+
return this.#threads.size;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create a new thread.
|
|
72
|
+
* @param {{ name: string, goal?: string, parentThreadId?: string }} spec
|
|
73
|
+
* @returns {Thread}
|
|
74
|
+
*/
|
|
75
|
+
create({ name, goal = '', parentThreadId = null } = {}) {
|
|
76
|
+
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
77
|
+
throw new Error('thread name is required');
|
|
78
|
+
}
|
|
79
|
+
if (parentThreadId && !this.#threads.has(parentThreadId)) {
|
|
80
|
+
throw new Error(`parent thread not found: ${parentThreadId}`);
|
|
81
|
+
}
|
|
82
|
+
const id = `thr-${randomUUID().slice(0, 8)}`;
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const thread = {
|
|
85
|
+
id,
|
|
86
|
+
name: name.trim(),
|
|
87
|
+
goal: goal || '',
|
|
88
|
+
parentThreadId: parentThreadId || null,
|
|
89
|
+
createdAt: now,
|
|
90
|
+
updatedAt: now,
|
|
91
|
+
};
|
|
92
|
+
this.#threads.set(id, thread);
|
|
93
|
+
return thread;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {string} id */
|
|
97
|
+
get(id) {
|
|
98
|
+
return this.#threads.get(id) || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @returns {Thread[]} */
|
|
102
|
+
list() {
|
|
103
|
+
return [...this.#threads.values()];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @param {string} id */
|
|
107
|
+
has(id) {
|
|
108
|
+
return this.#threads.has(id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Set the current thread marker. Throws if unknown.
|
|
113
|
+
* @param {string} id
|
|
114
|
+
*/
|
|
115
|
+
switch(id) {
|
|
116
|
+
if (!this.#threads.has(id)) {
|
|
117
|
+
throw new Error(`thread not found: ${id}`);
|
|
118
|
+
}
|
|
119
|
+
this.#currentId = id;
|
|
120
|
+
const t = this.#threads.get(id);
|
|
121
|
+
t.updatedAt = Date.now();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Attach a task to a thread. Overwrites any existing attachment.
|
|
126
|
+
* @param {string} threadId
|
|
127
|
+
* @param {string} taskId
|
|
128
|
+
*/
|
|
129
|
+
attachTask(threadId, taskId) {
|
|
130
|
+
if (!this.#threads.has(threadId)) {
|
|
131
|
+
throw new Error(`thread not found: ${threadId}`);
|
|
132
|
+
}
|
|
133
|
+
if (!taskId || typeof taskId !== 'string') {
|
|
134
|
+
throw new Error('taskId is required');
|
|
135
|
+
}
|
|
136
|
+
this.#attachments.set(threadId, taskId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get the taskId attached to a thread, if any.
|
|
141
|
+
* @param {string} threadId
|
|
142
|
+
* @returns {string|null}
|
|
143
|
+
*/
|
|
144
|
+
attachedTask(threadId) {
|
|
145
|
+
return this.#attachments.get(threadId) || null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @returns {Array<{ threadId: string, taskId: string }>} */
|
|
149
|
+
listAttachments() {
|
|
150
|
+
return [...this.#attachments.entries()].map(([threadId, taskId]) => ({ threadId, taskId }));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @type {ThreadStore|null} */
|
|
155
|
+
let threadStore = null;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Initialize the thread store. Safe to call multiple times — subsequent calls
|
|
159
|
+
* replace the store only if `force` is true (primarily for tests).
|
|
160
|
+
* @param {{ force?: boolean }} [opts]
|
|
161
|
+
* @returns {ThreadStore}
|
|
162
|
+
*/
|
|
163
|
+
export function initThreadStore(opts = {}) {
|
|
164
|
+
if (!threadStore || opts.force) {
|
|
165
|
+
threadStore = new ThreadStore();
|
|
166
|
+
}
|
|
167
|
+
return threadStore;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @returns {ThreadStore} */
|
|
171
|
+
export function getThreadStore() {
|
|
172
|
+
if (!threadStore) {
|
|
173
|
+
threadStore = new ThreadStore();
|
|
174
|
+
}
|
|
175
|
+
return threadStore;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Test-only reset helper. */
|
|
179
|
+
export function _resetThreadStoreForTests() {
|
|
180
|
+
threadStore = null;
|
|
181
|
+
}
|
package/unify/tools/index.js
CHANGED
|
@@ -55,6 +55,15 @@ import {
|
|
|
55
55
|
updatePlan,
|
|
56
56
|
} from './task-tools.js';
|
|
57
57
|
|
|
58
|
+
// --- P1 Thread tools (task-299 Phase 1) ---
|
|
59
|
+
import {
|
|
60
|
+
spawnThread,
|
|
61
|
+
switchThread,
|
|
62
|
+
listThreads,
|
|
63
|
+
attachThreadToTask,
|
|
64
|
+
} from './thread-tools.js';
|
|
65
|
+
import { spawnTask, spawnSubtask } from './spawn-task-tools.js';
|
|
66
|
+
|
|
58
67
|
// --- P2 Auxiliary tools ---
|
|
59
68
|
import { jsRepl, jsReplReset } from './js-repl.js';
|
|
60
69
|
import notebookEdit from './notebook-edit.js';
|
|
@@ -113,6 +122,14 @@ export const allTools = [
|
|
|
113
122
|
followupTask,
|
|
114
123
|
updatePlan,
|
|
115
124
|
|
|
125
|
+
// P1 Thread (task-299 Phase 1)
|
|
126
|
+
spawnThread,
|
|
127
|
+
switchThread,
|
|
128
|
+
listThreads,
|
|
129
|
+
attachThreadToTask,
|
|
130
|
+
spawnTask,
|
|
131
|
+
spawnSubtask,
|
|
132
|
+
|
|
116
133
|
// P2 Auxiliary
|
|
117
134
|
jsRepl,
|
|
118
135
|
jsReplReset,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spawn-task-tools.js — Thin semantic-sugar wrappers over TaskCreate.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 of task-299: expose `SpawnTask` and `SpawnSubtask` as first-class
|
|
5
|
+
* tool names so the LLM can "spawn" work items in the same vocabulary it
|
|
6
|
+
* uses to spawn threads. Both delegate to the existing TaskStore (shared
|
|
7
|
+
* with TaskCreate), so there is a single source of truth for task data.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineTool } from './types.js';
|
|
11
|
+
import { randomUUID } from 'crypto';
|
|
12
|
+
import { getTaskStore } from './task-tools.js';
|
|
13
|
+
|
|
14
|
+
function requireStore() {
|
|
15
|
+
const store = getTaskStore();
|
|
16
|
+
if (!store) return { error: 'Task store not initialized. Session may still be loading.' };
|
|
17
|
+
return { store };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function newTaskId() {
|
|
21
|
+
return `task-${randomUUID().slice(0, 8)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── SpawnTask ──────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export const spawnTask = defineTool({
|
|
27
|
+
name: 'SpawnTask',
|
|
28
|
+
description: `Spawn a new top-level task.
|
|
29
|
+
|
|
30
|
+
Equivalent to TaskCreate without a parent. Provided as a first-class
|
|
31
|
+
name so tasks and threads can be "spawned" with a uniform vocabulary.`,
|
|
32
|
+
parameters: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
title: { type: 'string' },
|
|
36
|
+
description: { type: 'string' },
|
|
37
|
+
priority: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
enum: ['low', 'medium', 'high', 'critical'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
required: ['title'],
|
|
43
|
+
},
|
|
44
|
+
modes: ['work'],
|
|
45
|
+
isConcurrencySafe: () => false,
|
|
46
|
+
isReadOnly: () => false,
|
|
47
|
+
async execute(input) {
|
|
48
|
+
const got = requireStore();
|
|
49
|
+
if (got.error) return JSON.stringify({ error: got.error });
|
|
50
|
+
const { title, description = '', priority = 'medium' } = input || {};
|
|
51
|
+
if (!title) return JSON.stringify({ error: 'title is required' });
|
|
52
|
+
|
|
53
|
+
const id = newTaskId();
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
got.store.create({
|
|
56
|
+
id,
|
|
57
|
+
title,
|
|
58
|
+
description,
|
|
59
|
+
priority,
|
|
60
|
+
status: 'pending',
|
|
61
|
+
parentId: null,
|
|
62
|
+
createdAt: now,
|
|
63
|
+
updatedAt: now,
|
|
64
|
+
});
|
|
65
|
+
return JSON.stringify({
|
|
66
|
+
success: true,
|
|
67
|
+
task: { id, title, priority, status: 'pending' },
|
|
68
|
+
message: `Task spawned: ${title} (${id})`,
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ─── SpawnSubtask ───────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export const spawnSubtask = defineTool({
|
|
76
|
+
name: 'SpawnSubtask',
|
|
77
|
+
description: `Spawn a subtask under an existing parent task.
|
|
78
|
+
|
|
79
|
+
Requires parent_task_id. The parent must exist. Use for breaking a
|
|
80
|
+
larger task into executable pieces.`,
|
|
81
|
+
parameters: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
parent_task_id: { type: 'string' },
|
|
85
|
+
title: { type: 'string' },
|
|
86
|
+
description: { type: 'string' },
|
|
87
|
+
priority: {
|
|
88
|
+
type: 'string',
|
|
89
|
+
enum: ['low', 'medium', 'high', 'critical'],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
required: ['parent_task_id', 'title'],
|
|
93
|
+
},
|
|
94
|
+
modes: ['work'],
|
|
95
|
+
isConcurrencySafe: () => false,
|
|
96
|
+
isReadOnly: () => false,
|
|
97
|
+
async execute(input) {
|
|
98
|
+
const got = requireStore();
|
|
99
|
+
if (got.error) return JSON.stringify({ error: got.error });
|
|
100
|
+
const { parent_task_id, title, description = '', priority = 'medium' } = input || {};
|
|
101
|
+
if (!parent_task_id) return JSON.stringify({ error: 'parent_task_id is required' });
|
|
102
|
+
if (!title) return JSON.stringify({ error: 'title is required' });
|
|
103
|
+
|
|
104
|
+
const parent = got.store.get(parent_task_id);
|
|
105
|
+
if (!parent) return JSON.stringify({ error: `Parent task not found: ${parent_task_id}` });
|
|
106
|
+
|
|
107
|
+
const id = newTaskId();
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
got.store.create({
|
|
110
|
+
id,
|
|
111
|
+
title,
|
|
112
|
+
description: description || `Subtask of: ${parent.title}`,
|
|
113
|
+
priority,
|
|
114
|
+
status: 'pending',
|
|
115
|
+
parentId: parent_task_id,
|
|
116
|
+
createdAt: now,
|
|
117
|
+
updatedAt: now,
|
|
118
|
+
});
|
|
119
|
+
return JSON.stringify({
|
|
120
|
+
success: true,
|
|
121
|
+
task: { id, title, priority, status: 'pending', parentId: parent_task_id },
|
|
122
|
+
message: `Subtask spawned: ${title} (${id}) under ${parent_task_id}`,
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* thread-tools.js — Thread-spawning tools for Unify Engine (Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 scope (task-299):
|
|
5
|
+
* - SpawnThread — create a new thread
|
|
6
|
+
* - SwitchThread — set the engine's currentThreadId marker
|
|
7
|
+
* - ListThreads — list threads + current marker
|
|
8
|
+
* - AttachThreadToTask — bind a thread to an existing task
|
|
9
|
+
*
|
|
10
|
+
* Phase 1 uses the in-memory ThreadStore (agent/unify/threads/store.js)
|
|
11
|
+
* so these tools can ship and be tested before task-298's file-backed
|
|
12
|
+
* data layer merges. The tool surface is designed to remain stable when
|
|
13
|
+
* the store is replaced.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { defineTool } from './types.js';
|
|
17
|
+
import { getThreadStore } from '../threads/store.js';
|
|
18
|
+
import { getTaskStore } from './task-tools.js';
|
|
19
|
+
|
|
20
|
+
// ─── SpawnThread ─────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export const spawnThread = defineTool({
|
|
23
|
+
name: 'SpawnThread',
|
|
24
|
+
description: `Create a new thread (conversation track) for parallel work.
|
|
25
|
+
|
|
26
|
+
A thread is a named conversation track that groups related messages and
|
|
27
|
+
tool calls under a single goal. Use when the work needs a fresh focus
|
|
28
|
+
track separate from the current conversation.
|
|
29
|
+
|
|
30
|
+
Returns the new threadId (format: "thr-xxxxxxxx"). Does NOT switch the
|
|
31
|
+
engine to the new thread — call SwitchThread to activate it.`,
|
|
32
|
+
parameters: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
name: { type: 'string', description: 'Short human-readable name' },
|
|
36
|
+
goal: { type: 'string', description: 'Optional one-sentence goal' },
|
|
37
|
+
parent_thread_id: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Optional parent threadId for hierarchy',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
required: ['name'],
|
|
43
|
+
},
|
|
44
|
+
modes: ['work'],
|
|
45
|
+
isConcurrencySafe: () => false,
|
|
46
|
+
isReadOnly: () => false,
|
|
47
|
+
async execute(input) {
|
|
48
|
+
const { name, goal, parent_thread_id } = input || {};
|
|
49
|
+
if (!name) return JSON.stringify({ error: 'name is required' });
|
|
50
|
+
try {
|
|
51
|
+
const store = getThreadStore();
|
|
52
|
+
const t = store.create({ name, goal, parentThreadId: parent_thread_id || null });
|
|
53
|
+
return JSON.stringify({
|
|
54
|
+
success: true,
|
|
55
|
+
thread: {
|
|
56
|
+
id: t.id,
|
|
57
|
+
name: t.name,
|
|
58
|
+
goal: t.goal,
|
|
59
|
+
parentThreadId: t.parentThreadId,
|
|
60
|
+
},
|
|
61
|
+
message: `Thread created: ${t.name} (${t.id})`,
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return JSON.stringify({ error: err.message || String(err) });
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ─── SwitchThread ────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export const switchThread = defineTool({
|
|
72
|
+
name: 'SwitchThread',
|
|
73
|
+
description: `Switch the engine's active thread marker.
|
|
74
|
+
|
|
75
|
+
All messages and tool calls persisted after this call will carry the new
|
|
76
|
+
threadId (Phase 1: marker only — the Engine reads it when persisting and
|
|
77
|
+
the web-bridge forwards it to the UI). Use 'main' to return to the root
|
|
78
|
+
thread.`,
|
|
79
|
+
parameters: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
thread_id: { type: 'string', description: 'Thread id to switch to' },
|
|
83
|
+
},
|
|
84
|
+
required: ['thread_id'],
|
|
85
|
+
},
|
|
86
|
+
modes: ['work'],
|
|
87
|
+
isConcurrencySafe: () => false,
|
|
88
|
+
isReadOnly: () => false,
|
|
89
|
+
async execute(input) {
|
|
90
|
+
const { thread_id } = input || {};
|
|
91
|
+
if (!thread_id) return JSON.stringify({ error: 'thread_id is required' });
|
|
92
|
+
try {
|
|
93
|
+
const store = getThreadStore();
|
|
94
|
+
store.switch(thread_id);
|
|
95
|
+
return JSON.stringify({
|
|
96
|
+
success: true,
|
|
97
|
+
currentThreadId: store.currentId,
|
|
98
|
+
message: `Switched to thread ${thread_id}`,
|
|
99
|
+
});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return JSON.stringify({ error: err.message || String(err) });
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ─── ListThreads ─────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export const listThreads = defineTool({
|
|
109
|
+
name: 'ListThreads',
|
|
110
|
+
description: `List all threads and the engine's current thread marker.`,
|
|
111
|
+
parameters: { type: 'object', properties: {} },
|
|
112
|
+
modes: ['work'],
|
|
113
|
+
isConcurrencySafe: () => true,
|
|
114
|
+
isReadOnly: () => true,
|
|
115
|
+
async execute() {
|
|
116
|
+
const store = getThreadStore();
|
|
117
|
+
const threads = store.list().map(t => ({
|
|
118
|
+
id: t.id,
|
|
119
|
+
name: t.name,
|
|
120
|
+
goal: t.goal,
|
|
121
|
+
parentThreadId: t.parentThreadId,
|
|
122
|
+
attachedTaskId: store.attachedTask(t.id),
|
|
123
|
+
}));
|
|
124
|
+
return JSON.stringify(
|
|
125
|
+
{
|
|
126
|
+
currentThreadId: store.currentId,
|
|
127
|
+
threads,
|
|
128
|
+
totalCount: threads.length,
|
|
129
|
+
},
|
|
130
|
+
null,
|
|
131
|
+
2,
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ─── AttachThreadToTask ──────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export const attachThreadToTask = defineTool({
|
|
139
|
+
name: 'AttachThreadToTask',
|
|
140
|
+
description: `Link an existing thread to an existing task.
|
|
141
|
+
|
|
142
|
+
Use to record which thread is responsible for which task. The thread and
|
|
143
|
+
the task must both already exist. Overwrites any previous attachment for
|
|
144
|
+
the same thread.`,
|
|
145
|
+
parameters: {
|
|
146
|
+
type: 'object',
|
|
147
|
+
properties: {
|
|
148
|
+
thread_id: { type: 'string' },
|
|
149
|
+
task_id: { type: 'string' },
|
|
150
|
+
},
|
|
151
|
+
required: ['thread_id', 'task_id'],
|
|
152
|
+
},
|
|
153
|
+
modes: ['work'],
|
|
154
|
+
isConcurrencySafe: () => false,
|
|
155
|
+
isReadOnly: () => false,
|
|
156
|
+
async execute(input) {
|
|
157
|
+
const { thread_id, task_id } = input || {};
|
|
158
|
+
if (!thread_id) return JSON.stringify({ error: 'thread_id is required' });
|
|
159
|
+
if (!task_id) return JSON.stringify({ error: 'task_id is required' });
|
|
160
|
+
|
|
161
|
+
const taskStore = getTaskStore();
|
|
162
|
+
// If the task store is initialized, validate the task exists; if not
|
|
163
|
+
// initialized (e.g. Phase 1 unit tests running before session bootstrap),
|
|
164
|
+
// skip the task existence check — we still enforce thread existence.
|
|
165
|
+
if (taskStore) {
|
|
166
|
+
const task = taskStore.get(task_id);
|
|
167
|
+
if (!task) return JSON.stringify({ error: `Task not found: ${task_id}` });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const store = getThreadStore();
|
|
172
|
+
store.attachTask(thread_id, task_id);
|
|
173
|
+
return JSON.stringify({
|
|
174
|
+
success: true,
|
|
175
|
+
threadId: thread_id,
|
|
176
|
+
taskId: task_id,
|
|
177
|
+
message: `Thread ${thread_id} attached to task ${task_id}`,
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return JSON.stringify({ error: err.message || String(err) });
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
});
|
package/unify/web-bridge.js
CHANGED
|
@@ -192,12 +192,15 @@ export async function handleUnifyChat(msg) {
|
|
|
192
192
|
input: event.input,
|
|
193
193
|
}],
|
|
194
194
|
},
|
|
195
|
+
threadId: event.threadId,
|
|
195
196
|
});
|
|
196
197
|
break;
|
|
197
198
|
|
|
198
199
|
// ── Tool execution started ──
|
|
199
200
|
case 'tool_start':
|
|
200
|
-
// Tool is running — the UI already shows it from tool_use block above
|
|
201
|
+
// Tool is running — the UI already shows it from tool_use block above.
|
|
202
|
+
// Forward threadId so the UI can group tool activity by thread (Phase 1).
|
|
203
|
+
sendUnifyEvent({ type: 'tool_start', id: event.id, name: event.name, threadId: event.threadId });
|
|
201
204
|
break;
|
|
202
205
|
|
|
203
206
|
// ── Tool execution completed ──
|
|
@@ -211,6 +214,7 @@ export async function handleUnifyChat(msg) {
|
|
|
211
214
|
content: event.output || '',
|
|
212
215
|
is_error: event.isError || false,
|
|
213
216
|
}],
|
|
217
|
+
threadId: event.threadId,
|
|
214
218
|
});
|
|
215
219
|
break;
|
|
216
220
|
|