xitto-kernel 0.3.2 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3
4
+
5
+ - **背景任務 + 完成通知(非同步交互)**:server 新增「派任務→通知」形態,把 agent 當同事用。
6
+ - `POST /v1/tasks`:立刻回 `202 + taskId`,後台跑,完成 POST 結果到 `webhook`
7
+ - `GET /v1/tasks`、`GET /v1/tasks/:id`:列表 / 狀態 + 結果
8
+ - `GET /v1/tasks/:id/events`:附掛事件流(SSE,replay 緩衝 + 即時)
9
+ - 限流並發 `XITTO_SERVER_CONCURRENCY`(預設 2)
10
+ - 抽出 `createTaskStore`(純記憶體、可測)與 `mapEvent`;`/v1/run`/`/v1/stream` 共用 `runKernel`
11
+ - 5 個任務佇列測試(狀態轉移 / 限流 / 事件緩衝 / 完成回呼 / 訂閱)
12
+
3
13
  ## 0.3.2
4
14
 
5
15
  - **沒設定就啟動 → 直接進導引**:偵測到沒有 providers.json 且在真實終端時,
package/README.md CHANGED
@@ -72,6 +72,20 @@ curl -s -XPOST localhost:8787/v1/run -H "Authorization: Bearer secret" \
72
72
  結構化 JSON 日誌(審計/觀測)、6 個 pack 可選、JSON 或 SSE(`/v1/stream`)串流。
73
73
  「個人 vs 生產」是 **app 層**的事 —— 同一個 kernel,CLI 與 server 是兩個 app。
74
74
 
75
+ **背景任務 + 完成通知(非同步交互)** —— 派任務出去、立刻拿到 `taskId`、做完回呼 webhook,不用一直盯著:
76
+ ```bash
77
+ # 派任務(立刻回 202 + taskId),完成時 POST 結果到 webhook
78
+ curl -s -XPOST localhost:8787/v1/tasks -H "Authorization: Bearer secret" \
79
+ -H content-type:application/json \
80
+ -d '{"pack":"general","mode":"goal","goal":"...","webhook":"https://你的服務/done"}'
81
+
82
+ curl -s localhost:8787/v1/tasks -H "Authorization: Bearer secret" # 列表
83
+ curl -s localhost:8787/v1/tasks/<id> -H "Authorization: Bearer secret" # 狀態 + 結果
84
+ curl -sN localhost:8787/v1/tasks/<id>/events -H "Authorization: Bearer secret" # 附掛事件流(SSE,replay+即時)
85
+ ```
86
+ 限流並發 `XITTO_SERVER_CONCURRENCY`(預設 2);webhook 完成時收到 `{taskId,status,text,usage,rounds,done}`。
87
+ 這把「即時盯著看」延伸到「派任務→通知」的非同步形態(像把 agent 當同事)。
88
+
75
89
  ## 做你自己的領域 agent(不固化)
76
90
 
77
91
  kernel 是**被依賴的套件**,不是被 clone 的範本。你的 agent 是獨立小專案:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
package/src/app/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Server app(PoC)— 把 kernel 包成 HTTP 服務(零依賴 node:http)。
2
2
  // 證明 kernel 能脫離 CLI 跑成服務:bearer token 認證、per-session 隔離工作目錄、沙箱、結構化日誌、
3
- // JSON 或 SSE 串流。這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
3
+ // JSON 或 SSE 串流,以及「背景任務 + 完成通知(webhook)」—— 派任務出去、做完回呼,不用一直盯著。
4
+ // 這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
4
5
  import { createServer } from 'node:http';
5
6
  import { mkdirSync } from 'node:fs';
6
7
  import { join } from 'node:path';
@@ -20,7 +21,75 @@ const PACKS = {
20
21
  };
21
22
 
22
23
  const lastText = (history) => ([...(history || [])].reverse().find((m) => m.role === 'assistant')?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
23
- const newId = () => 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
24
+ const newId = (p = 's') => p + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
25
+
26
+ // 把原始 kernel 事件壓成精簡的對外事件(串流端與背景任務共用,避免重複映射)
27
+ export const mapEvent = (ev) => {
28
+ if (ev.type === 'tool_execution_start') return { type: 'tool', name: ev.toolName, args: ev.args };
29
+ if (ev.type === 'tool_execution_end') return { type: 'tool_end', name: ev.toolName, isError: !!ev.isError };
30
+ if (ev.type === 'message_update' && ev.assistantMessageEvent?.type === 'text_delta') return { type: 'text', delta: ev.assistantMessageEvent.delta };
31
+ return null;
32
+ };
33
+
34
+ /**
35
+ * 背景任務佇列(純記憶體、可測,與 HTTP 無關)。
36
+ * 派任務 → 限流跑 → 緩衝事件供事後附掛 → 完成回呼(webhook)。
37
+ * @param {Object} o
38
+ * @param {(spec:object, emit:(ev:object)=>void)=>Promise<any>} o.runJob 實際執行(回傳值=任務結果)
39
+ * @param {number} [o.concurrency] 同時跑幾個(預設 2)
40
+ * @param {(task:object)=>void} [o.onFinish] 每個任務 settle 後呼叫(拿來發 webhook)
41
+ * @param {number} [o.maxEvents] 每任務保留最近幾筆事件(預設 500)
42
+ */
43
+ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents = 500 } = {}) {
44
+ const tasks = new Map(); // id -> task
45
+ const queue = []; // 等待中的 task
46
+ const subs = new Map(); // id -> Set<(ev)=>void>
47
+ let active = 0;
48
+
49
+ const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', mode: t.spec.mode || 'turn', sessionId: t.result?.sessionId || t.spec.sessionId || null, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error });
50
+
51
+ const emit = (t, ev) => {
52
+ t.events.push(ev);
53
+ if (t.events.length > maxEvents) t.events.shift();
54
+ const s = subs.get(t.id); if (s) for (const fn of s) { try { fn(ev); } catch { /* 訂閱端錯不影響任務 */ } }
55
+ };
56
+
57
+ function pump() {
58
+ while (active < concurrency && queue.length) {
59
+ const t = queue.shift();
60
+ active++;
61
+ t.status = 'running'; t.startedAt = new Date().toISOString();
62
+ emit(t, { type: 'status', status: 'running' });
63
+ Promise.resolve()
64
+ .then(() => runJob(t.spec, (ev) => emit(t, ev)))
65
+ .then((result) => { t.status = 'done'; t.result = result; })
66
+ .catch((e) => { t.status = 'error'; t.error = e.message || String(e); })
67
+ .finally(() => {
68
+ t.finishedAt = new Date().toISOString();
69
+ emit(t, { type: 'end', status: t.status, result: t.result, error: t.error });
70
+ active--;
71
+ try { onFinish?.(t); } catch { /* webhook 錯不影響佇列 */ }
72
+ pump();
73
+ });
74
+ }
75
+ }
76
+
77
+ return {
78
+ enqueue(spec) {
79
+ const t = { id: newId('t'), status: 'queued', spec: spec || {}, events: [], result: null, error: null, createdAt: new Date().toISOString(), startedAt: null, finishedAt: null };
80
+ tasks.set(t.id, t);
81
+ queue.push(t);
82
+ pump();
83
+ return t;
84
+ },
85
+ get: (id) => tasks.get(id),
86
+ view: (id) => { const t = tasks.get(id); return t ? view(t) : null; },
87
+ result: (id) => { const t = tasks.get(id); return t ? { ...view(t), result: t.result } : null; },
88
+ list: () => [...tasks.values()].map(view),
89
+ subscribe(id, fn) { let s = subs.get(id); if (!s) { s = new Set(); subs.set(id, s); } s.add(fn); return () => s.delete(fn); },
90
+ stats: () => ({ active, queued: queue.length, total: tasks.size }),
91
+ };
92
+ }
24
93
 
25
94
  /**
26
95
  * @param {Object} o
@@ -29,9 +98,10 @@ const newId = () => 's' + Date.now().toString(36) + Math.random().toString(36).s
29
98
  * @param {string} [o.token] bearer token(未設=不驗證,僅 PoC)
30
99
  * @param {string} [o.baseDir] 每個 session 的隔離工作目錄根
31
100
  * @param {boolean} [o.sandbox] 是否沙箱(預設 true:服務端跑 agent 應隔離)
101
+ * @param {number} [o.concurrency] 背景任務同時數(預設 2)
32
102
  * @returns {import('node:http').Server}
33
103
  */
34
- export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true } = {}) {
104
+ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true, concurrency = 2 } = {}) {
35
105
  const sessions = new Map(); // sessionId -> { pack, history }
36
106
  mkdirSync(baseDir, { recursive: true });
37
107
 
@@ -39,49 +109,93 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
39
109
  const authed = (req) => !token || (req.headers.authorization === `Bearer ${token}`);
40
110
  const log = (o) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...o }));
41
111
  const readBody = (req) => new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; if (b.length > 1e6) req.destroy(); }); req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } }); });
112
+ const sseHead = (res) => res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
113
+
114
+ // 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel 事件
115
+ async function runKernel(spec, onEvent) {
116
+ const make = PACKS[spec.pack || 'general'];
117
+ if (!make) throw new Error(`未知 pack「${spec.pack}」,可用:${Object.keys(PACKS).join(', ')}`);
118
+ const sessionId = spec.sessionId || newId();
119
+ const sess = sessions.get(sessionId) || { pack: spec.pack || 'general', history: [] };
120
+ const workdir = join(baseDir, sessionId); mkdirSync(workdir, { recursive: true });
121
+ const kernel = createKernel(make({ cwd: workdir }), { cwd: workdir, model, getApiKey, sandbox: { enabled: sandbox }, getSandbox: () => sandbox, confirm: async () => 'yes' });
122
+ const usage = { input: 0, output: 0 };
123
+ const wrapped = (ev) => { if (ev.type === 'message_end' && ev.message?.usage) { usage.input += ev.message.usage.input || 0; usage.output += ev.message.usage.output || 0; } onEvent?.(ev); };
124
+ const r = (spec.mode === 'goal')
125
+ ? await kernel.runGoal(spec.goal || spec.input || '', { history: sess.history, onEvent: wrapped })
126
+ : await kernel.runTurn(spec.input || '', { history: sess.history, onEvent: wrapped });
127
+ sess.history = r.messages || r.history || []; sessions.set(sessionId, sess);
128
+ return { sessionId, text: r.text ?? lastText(sess.history), usage, rounds: r.rounds, done: r.done };
129
+ }
130
+
131
+ // 完成通知:POST 結果到 spec.webhook(http/https),單次嘗試、失敗記日誌不重試(PoC)
132
+ async function fireWebhook(task) {
133
+ const url = task.spec.webhook; if (!url || !/^https?:\/\//.test(url)) return;
134
+ const r = task.result || {};
135
+ const body = JSON.stringify({ taskId: task.id, status: task.status, error: task.error, sessionId: r.sessionId, text: r.text, usage: r.usage, rounds: r.rounds, done: r.done, finishedAt: task.finishedAt });
136
+ try { const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body }); log({ webhook: url, task: task.id, status: task.status, code: resp.status }); }
137
+ catch (e) { log({ webhook: url, task: task.id, error: e.message }); }
138
+ }
139
+
140
+ const tasks = createTaskStore({
141
+ concurrency,
142
+ runJob: (spec, emit) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }),
143
+ onFinish: (task) => { log({ task: task.id, pack: task.spec.pack, mode: task.spec.mode || 'turn', status: task.status, ms: task.startedAt ? Date.parse(task.finishedAt) - Date.parse(task.startedAt) : 0 }); fireWebhook(task); },
144
+ });
42
145
 
43
146
  return createServer(async (req, res) => {
44
147
  const url = new URL(req.url, 'http://localhost');
45
- if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, { ok: true, packs: Object.keys(PACKS), model: model.id });
148
+ const path = url.pathname;
149
+ if (req.method === 'GET' && path === '/health') return json(res, 200, { ok: true, packs: Object.keys(PACKS), model: model.id, tasks: tasks.stats() });
46
150
  if (!authed(req)) return json(res, 401, { error: 'unauthorized(帶 Authorization: Bearer <token>)' });
47
151
 
48
- if (req.method === 'POST' && (url.pathname === '/v1/run' || url.pathname === '/v1/stream')) {
152
+ // 同步:跑完才回(JSON SSE 串流)
153
+ if (req.method === 'POST' && (path === '/v1/run' || path === '/v1/stream')) {
49
154
  const body = await readBody(req);
50
- const make = PACKS[body.pack || 'general'];
51
- if (!make) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
52
- const sessionId = body.sessionId || newId();
53
- const sess = sessions.get(sessionId) || { pack: body.pack || 'general', history: [] };
54
- const workdir = join(baseDir, sessionId); mkdirSync(workdir, { recursive: true });
55
- const kernel = createKernel(make({ cwd: workdir }), { cwd: workdir, model, getApiKey, sandbox: { enabled: sandbox }, getSandbox: () => sandbox, confirm: async () => 'yes' });
56
-
57
- const usage = { input: 0, output: 0 };
58
- const streaming = url.pathname === '/v1/stream';
59
- if (streaming) res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
155
+ const streaming = path === '/v1/stream';
156
+ if (streaming) sseHead(res);
60
157
  const sse = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
61
- const onEvent = (ev) => {
62
- if (ev.type === 'message_end' && ev.message?.usage) { usage.input += ev.message.usage.input || 0; usage.output += ev.message.usage.output || 0; }
63
- if (!streaming) return;
64
- if (ev.type === 'tool_execution_start') sse({ type: 'tool', name: ev.toolName, args: ev.args });
65
- else if (ev.type === 'message_update' && ev.assistantMessageEvent?.type === 'text_delta') sse({ type: 'text', delta: ev.assistantMessageEvent.delta });
66
- };
67
-
68
158
  const t0 = Date.now();
69
159
  try {
70
- const r = (body.mode === 'goal')
71
- ? await kernel.runGoal(body.goal || body.input || '', { history: sess.history, onEvent })
72
- : await kernel.runTurn(body.input || '', { history: sess.history, onEvent });
73
- sess.history = r.messages || r.history || []; sessions.set(sessionId, sess);
74
- const text = r.text ?? lastText(sess.history);
75
- log({ pack: sess.pack, session: sessionId, mode: body.mode || 'turn', tokens: usage.input + usage.output, rounds: r.rounds, ms: Date.now() - t0 });
76
- const payload = { sessionId, text, usage, rounds: r.rounds, done: r.done };
77
- if (streaming) { sse({ type: 'done', ...payload }); res.end(); }
78
- else json(res, 200, payload);
160
+ const r = await runKernel(body, streaming ? (ev) => { const m = mapEvent(ev); if (m) sse(m); } : undefined);
161
+ log({ pack: body.pack || 'general', session: r.sessionId, mode: body.mode || 'turn', tokens: r.usage.input + r.usage.output, rounds: r.rounds, ms: Date.now() - t0 });
162
+ if (streaming) { sse({ type: 'done', ...r }); res.end(); } else json(res, 200, r);
79
163
  } catch (e) {
80
- log({ pack: sess.pack, session: sessionId, error: e.message });
81
- if (streaming) { sse({ type: 'error', error: e.message }); res.end(); } else json(res, 500, { error: e.message });
164
+ log({ pack: body.pack, error: e.message });
165
+ if (streaming) { sse({ type: 'error', error: e.message }); res.end(); } else json(res, e.message?.startsWith('未知 pack') ? 400 : 500, { error: e.message });
82
166
  }
83
167
  return;
84
168
  }
169
+
170
+ // 背景任務:立刻回 taskId,後台跑,完成發 webhook
171
+ if (req.method === 'POST' && path === '/v1/tasks') {
172
+ const body = await readBody(req);
173
+ if (!PACKS[body.pack || 'general']) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
174
+ if (body.webhook && !/^https?:\/\//.test(body.webhook)) return json(res, 400, { error: 'webhook 需為 http(s) URL' });
175
+ const t = tasks.enqueue({ pack: body.pack, mode: body.mode, input: body.input, goal: body.goal, sessionId: body.sessionId, webhook: body.webhook });
176
+ log({ task: t.id, action: 'enqueue', pack: body.pack || 'general', mode: body.mode || 'turn' });
177
+ return json(res, 202, { taskId: t.id, status: t.status, ...tasks.stats() });
178
+ }
179
+ if (req.method === 'GET' && path === '/v1/tasks') return json(res, 200, { tasks: tasks.list(), ...tasks.stats() });
180
+
181
+ // 任務狀態 / 結果
182
+ const mTask = path.match(/^\/v1\/tasks\/([^/]+)$/);
183
+ if (req.method === 'GET' && mTask) { const v = tasks.result(mTask[1]); return v ? json(res, 200, v) : json(res, 404, { error: 'task not found' }); }
184
+
185
+ // 附掛背景任務的事件流(replay 緩衝 + 即時;已結束則回放後關閉)
186
+ const mEv = path.match(/^\/v1\/tasks\/([^/]+)\/events$/);
187
+ if (req.method === 'GET' && mEv) {
188
+ const task = tasks.get(mEv[1]);
189
+ if (!task) return json(res, 404, { error: 'task not found' });
190
+ sseHead(res);
191
+ const sse = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
192
+ for (const ev of task.events) sse(ev);
193
+ if (task.status === 'done' || task.status === 'error') { res.end(); return; }
194
+ const unsub = tasks.subscribe(task.id, (ev) => { sse(ev); if (ev.type === 'end') { try { unsub(); } catch { /* 略 */ } res.end(); } });
195
+ req.on('close', () => { try { unsub(); } catch { /* 略 */ } });
196
+ return;
197
+ }
198
+
85
199
  json(res, 404, { error: 'not found' });
86
200
  });
87
201
  }
@@ -90,11 +204,13 @@ export function startServer() {
90
204
  const port = Number(process.env.PORT || 8787);
91
205
  const token = process.env.XITTO_SERVER_TOKEN || 'dev-token';
92
206
  const sandbox = process.env.XITTO_SERVER_SANDBOX !== 'off';
207
+ const concurrency = Number(process.env.XITTO_SERVER_CONCURRENCY || 2);
93
208
  const { model, getApiKey } = loadModel(process.env.XITTO_MODEL);
94
- const server = createServerApp({ model, getApiKey, token, sandbox });
209
+ const server = createServerApp({ model, getApiKey, token, sandbox, concurrency });
95
210
  server.listen(port, () => {
96
- console.log(`xitto-kernel server · http://localhost:${port} · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'}`);
211
+ console.log(`xitto-kernel server · http://localhost:${port} · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}`);
97
212
  console.log(`token: ${token === 'dev-token' ? 'dev-token(請設 XITTO_SERVER_TOKEN)' : '(已設定)'}`);
213
+ console.log('路由:POST /v1/run · /v1/stream · /v1/tasks(背景+webhook)|GET /v1/tasks[/:id[/events]] · /health');
98
214
  });
99
215
  return server;
100
216
  }