xitto-kernel 0.8.5 → 0.9.2
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 +28 -0
- package/README.md +1 -3
- package/package.json +1 -1
- package/src/app/server.js +1 -1
- package/src/app/web/index.html +47 -63
- package/src/kernel/agent-loop.js +6 -0
- package/src/kernel/index.js +5 -1
- package/src/packs/coding/index.js +5 -2
- package/src/packs/general/index.js +6 -3
- package/src/packs/shared/fs-tools.js +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.2
|
|
4
|
+
|
|
5
|
+
- **修:報告顯示完成但找不到真實檔案**(成品寫到工作區外)。從執行歷史查出:某任務 workspace=`/Users/…/Xiza`(本地就地),但 agent 把報告 `write` 到 `/tmp/…`、`/app/…`(絕對路徑,工作區外)→ 成品掃描只看工作區 → `artifacts:{created:[]}`,但 summary 說完成 → 使用者看到「有報告」卻找不到檔。兩道修法:
|
|
6
|
+
- **告知工作目錄**:system prompt 明確寫出 cwd +「請用相對路徑寫在此目錄內,不要寫到 /tmp、/app 等外面」(agent 原本不知道工作目錄在哪,只能亂猜)
|
|
7
|
+
- **寫檔限制在工作區內**:general/coding/fs-tools 的 `write`/`edit` confine 到 cwd,逃逸(絕對路徑外/`../`)直接擋下並回錯誤;讀檔不限制
|
|
8
|
+
- 4 個新測試(相對 OK/絕對逃逸擋/`..` 擋/`/app` 擋)+ 真實 model 端到端(report.md 落在工作區內、artifacts 正確)。測試 192/192
|
|
9
|
+
|
|
10
|
+
## 0.9.1
|
|
11
|
+
|
|
12
|
+
- **修:任務一直迴圈無法結束**。從持久化的執行歷史分析出:某任務(查 2026 世界盃淘汰賽賽程——資訊不存在)35 步 6 輪、web_search×11 + web_fetch×23 不停繞圈,因為查不到、驗收一直判未達成、agent 每輪都有動作所以「無進展」偵測不到 → 跑到上限/被手動取消。三道防護:
|
|
13
|
+
- **目標迴圈**:驗收回饋連續重複(agent 在繞圈、沒朝驗收要求收斂)→ 連 2 次相同就停(stalled),不再因「有動作」而空轉到上限
|
|
14
|
+
- **agent loop 硬上限**:單回合工具呼叫達 `maxSteps`(80) → 注入「別再用工具,用現有資訊作結」逼它收尾(修 `while(true)` 無上限的潛在無限迴圈)
|
|
15
|
+
- **server**:goal 任務 `maxRounds` 由 12 降到 8(許願台 fire-and-forget,不宜跑太久)
|
|
16
|
+
- 1 個新測試(不可達成目標在 ≤4 輪內停止,非跑到上限)。測試 190/190
|
|
17
|
+
|
|
18
|
+
## 0.9.0
|
|
19
|
+
|
|
20
|
+
- **許願台改單頁佈局(移除分頁,許願與工作台同頁呈現)**:原本「許願 | 工作台」要切分頁。改成一頁看全部:
|
|
21
|
+
- **頂部** = 許願輸入;**左欄** = 歷史成品 + 📂 檔案瀏覽器(各自獨立內捲,都到得了底);**主區** = 當前任務/進度/成品 + 檔案預覽(共用一個 #fview)
|
|
22
|
+
- 點任一檔(成品檔 或 工作區檔)都在**同一個主區預覽**;檔案瀏覽器逐層導航
|
|
23
|
+
- 任務完成自動刷新左欄檔案列;切換空間/新專案/選資料夾都同步刷新歷史+檔案
|
|
24
|
+
- 移除 tab 切換邏輯與多餘 DOM;交辦、看歷史、瀏覽檔案、預覽全在一頁,不再跳來跳去
|
|
25
|
+
- 純前端重構;測試 189/189 + 內嵌 JS 語法檢查 + 服務頁面結構驗證(.layout/.nav/.work/#fview、無 tabs、div 平衡)
|
|
26
|
+
|
|
27
|
+
## 0.8.6
|
|
28
|
+
|
|
29
|
+
- **修:工作台預覽開啟時目錄捲不動**。`.wbleft`(檔案列) 是 `position:sticky`,清單比視窗高時 top 被釘在 14px、底部就到不了(sticky 陷阱,預覽讓整列變高時浮現)。改成側欄/檔案列/預覽**各自獨立內捲**(`max-height:calc(100vh - 28px) + overflow:auto`),不再靠頁面捲動;窄螢幕(<=860px)回到單欄、取消內捲。測試 189/189。
|
|
30
|
+
|
|
3
31
|
## 0.8.5
|
|
4
32
|
|
|
5
33
|
- **許願台重啟後歷史還在(持久化)**:原本任務清單與對話 session 都是 in-memory,重啟全沒。改成落地:
|
package/README.md
CHANGED
|
@@ -110,9 +110,7 @@ npm run serve:local # = LOCAL=1 SANDBOX=off,token 預設
|
|
|
110
110
|
- **繼續/調整(迭代有脈絡)**:成品上有「↳ 繼續/調整這個成果」——打一句想改什麼/想深入什麼,送出一個**後續任務**,**接續這次的對話(sessionId)+ 同工作區**。agent 同時有「檔案 + 當時的討論與理由」,不只是檔案。預設每個許願是乾淨新對話(不暴脹),按「繼續」才接續那條線(像 ChatGPT 開新對話 vs 接著聊)。歷史以 `↳` 標出接續鏈
|
|
111
111
|
- **歷史成品**:過往交辦的清單(願望 + 狀態),不是聊天串
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
**工作台分頁(看見並管理你的工作區)**:許願台頂部有「許願 | 📂 工作台」分頁(同頁切換,不是另開頁面)。**許願**=交辦任務的主流程;**工作台**=**逐層瀏覽**當前專案的檔案(像檔案總管:只列當前目錄的子資料夾+檔案,點資料夾才進去,不一次遞迴攤平),看/下載/刪。單一任務用許願就夠;做專案的人切到工作台看「我累積了什麼、拿任意檔、清理」——讓持久工作空間從隱形變成可見可管理。刻意保持輕量(檔案清單,不是 IDE)。
|
|
113
|
+
**單頁佈局(無分頁,一眼看全部)**:頂部**許願輸入** + 左欄(**歷史成品** + **📂 檔案瀏覽器**,各自內捲)+ 主區(**當前任務/進度/成品/檔案預覽**共用)。不用切分頁——交辦任務、看歷史、瀏覽工作區檔案、預覽內容都在同一頁。檔案瀏覽器**逐層導航**(像檔案總管,不一次遞迴攤平),點任一檔(成品或工作區)都在主區預覽。容器 1180px,窄螢幕(≤860px)自動收成單欄。刻意保持輕量(不是 IDE)。
|
|
116
114
|
|
|
117
115
|
**持久工作空間(成品間的關係)**:每個成品是**獨立的對話**(不續接前一個,避免 context 暴脹),但**共用一個持久工作空間**(`.xitto-server/ws/<workspace>`,預設 `default`)——所以 ① **檔案留存**,後面的任務能接續前面的成果(「把我上次做的 plan.md 翻成英文」);② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——它**越用越懂你**,不再是每次都從零開始的陌生人。`workspace` 可在 POST 時指定(多使用者各自一個);網頁有「專案」下拉切換,每份成品卡標出 `📁 所屬空間`。
|
|
118
116
|
|
package/package.json
CHANGED
package/src/app/server.js
CHANGED
|
@@ -272,7 +272,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
272
272
|
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); };
|
|
273
273
|
if (spec.mode === 'goal') {
|
|
274
274
|
// 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
|
|
275
|
-
const o = await kernel.runOutcome(spec.goal || spec.input ||
|
|
275
|
+
const o = await kernel.runOutcome(spec.goal || spec.input || "", { maxRounds: 8, history: sess.history, onEvent: wrapped, onAgent, onRound: (i) => wrapped({ type: 'round', round: i.round, maxRounds: i.maxRounds }) });
|
|
276
276
|
sess.history = o.history || []; sessions.set(sessionId, sess); persistSession(sessionId, sess);
|
|
277
277
|
try { rmSync(join(workdir, 'tmp'), { recursive: true, force: true }); } catch { /* 清過程檔,失敗無妨 */ }
|
|
278
278
|
// 溯源:邏輯位置 workspace 永遠記;實體路徑只在本地模式給(託管不洩漏伺服器路徑)
|
package/src/app/web/index.html
CHANGED
|
@@ -9,13 +9,17 @@
|
|
|
9
9
|
* { box-sizing:border-box; }
|
|
10
10
|
body { margin:0; font:15px/1.6 -apple-system,"PingFang TC","Microsoft JhengHei",system-ui,sans-serif; background:var(--bg); color:var(--fg); }
|
|
11
11
|
.wrap { max-width:1180px; margin:0 auto; padding:24px 22px 80px; }
|
|
12
|
-
/*
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
.
|
|
16
|
-
.
|
|
17
|
-
.
|
|
18
|
-
|
|
12
|
+
/* 單頁佈局:頂部許願 + 左欄(歷史+檔案) + 主區(當前任務/成品/預覽共用)。窄螢幕收單欄 */
|
|
13
|
+
.layout { display:grid; grid-template-columns:300px minmax(0,1fr); gap:24px; align-items:start; margin-top:18px; }
|
|
14
|
+
.nav { position:sticky; top:14px; max-height:calc(100vh - 28px); display:flex; flex-direction:column; gap:16px; }
|
|
15
|
+
.navsec { display:flex; flex-direction:column; min-height:0; }
|
|
16
|
+
.navsec h3 { margin:0 0 8px; }
|
|
17
|
+
.hist-sec #history, .file-sec #wbfiles { overflow:auto; } /* 兩段各自內捲,都到得了底 */
|
|
18
|
+
.hist-sec { flex:0 1 auto; max-height:42vh; } .hist-sec #history { max-height:38vh; }
|
|
19
|
+
.file-sec { flex:1 1 auto; min-height:0; } .file-sec #wbfiles { flex:1; }
|
|
20
|
+
.work { min-width:0; }
|
|
21
|
+
.welcome { padding:40px 16px; text-align:center; line-height:2; }
|
|
22
|
+
@media (max-width:860px){ .layout{ grid-template-columns:1fr; } .nav{ position:static; max-height:none; } .hist-sec,.hist-sec #history,.file-sec #wbfiles{ max-height:none; } }
|
|
19
23
|
header { display:flex; align-items:baseline; gap:10px; margin-bottom:6px; }
|
|
20
24
|
header h1 { font-size:22px; margin:0; }
|
|
21
25
|
header .sub { color:var(--dim); font-size:13px; }
|
|
@@ -72,9 +76,6 @@
|
|
|
72
76
|
.hist:hover { border-color:var(--accent); }
|
|
73
77
|
.hist .g { font-size:14px; } .hist .m { color:var(--dim); font-size:12px; }
|
|
74
78
|
.empty { color:var(--dim); font-size:13px; }
|
|
75
|
-
.tabs { display:flex; gap:4px; border-bottom:1px solid var(--line); margin:14px 0 4px; }
|
|
76
|
-
.tab { padding:8px 16px; font-size:14px; color:var(--dim); cursor:pointer; border-bottom:2px solid transparent; }
|
|
77
|
-
.tab.active { color:var(--fg); border-bottom-color:var(--accent); }
|
|
78
79
|
.wbrow { display:flex; align-items:center; gap:10px; padding:9px 12px; background:var(--card); border:1px solid var(--line); border-radius:8px; margin:6px 0; }
|
|
79
80
|
.wbname { flex:1; color:var(--accent); cursor:pointer; font-size:14px; }
|
|
80
81
|
.wbmeta { color:var(--dim); font-size:12px; }
|
|
@@ -126,38 +127,30 @@
|
|
|
126
127
|
</div>
|
|
127
128
|
</div>
|
|
128
129
|
|
|
129
|
-
<div class="
|
|
130
|
-
<
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
<div class="main">
|
|
136
|
-
<div class="ask">
|
|
137
|
-
<textarea id="goal" placeholder="例如:把這個資料夾的 .md 檔整理成一份目錄 index.md;或:抓 example.com 的標題寫進 title.txt"></textarea>
|
|
138
|
-
<div class="row">
|
|
139
|
-
<select id="pack" title="領域"></select>
|
|
140
|
-
<span class="spacer"></span>
|
|
141
|
-
<button id="go">交辦 →</button>
|
|
142
|
-
</div>
|
|
143
|
-
</div>
|
|
144
|
-
<div id="current"></div>
|
|
130
|
+
<div class="ask">
|
|
131
|
+
<textarea id="goal" placeholder="說出你想完成的事⋯例如:把這個資料夾的 .md 整理成目錄 index.md;或抓 example.com 標題寫進 title.txt"></textarea>
|
|
132
|
+
<div class="row">
|
|
133
|
+
<select id="pack" title="領域"></select>
|
|
134
|
+
<span class="spacer"></span>
|
|
135
|
+
<button id="go">交辦 →</button>
|
|
145
136
|
</div>
|
|
146
|
-
<aside class="side">
|
|
147
|
-
<h3>歷史成品</h3>
|
|
148
|
-
<div id="history"><div class="empty">還沒有任何任務。<br>左邊交辦一件事試試。</div></div>
|
|
149
|
-
</aside>
|
|
150
137
|
</div>
|
|
151
138
|
|
|
152
|
-
<div
|
|
153
|
-
<
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
139
|
+
<div class="layout">
|
|
140
|
+
<aside class="nav">
|
|
141
|
+
<section class="navsec hist-sec">
|
|
142
|
+
<h3>歷史成品</h3>
|
|
143
|
+
<div id="history"><div class="empty">還沒有任何任務。上面交辦一件事試試。</div></div>
|
|
144
|
+
</section>
|
|
145
|
+
<section class="navsec file-sec">
|
|
146
|
+
<h3>📂 檔案</h3>
|
|
147
|
+
<div id="wbfiles"></div>
|
|
148
|
+
</section>
|
|
149
|
+
</aside>
|
|
150
|
+
<main class="work">
|
|
151
|
+
<div id="current"><div class="empty welcome">👋 上面說出你想完成的事,交給它去做。<br>左邊看歷史成品與工作區檔案,點任一個在這裡展開。</div></div>
|
|
152
|
+
<div class="viewer" id="fview" style="display:none"></div>
|
|
153
|
+
</main>
|
|
161
154
|
</div>
|
|
162
155
|
</div>
|
|
163
156
|
|
|
@@ -194,7 +187,8 @@ let spaces = JSON.parse(localStorage.getItem("xk_spaces")||'["default"]');
|
|
|
194
187
|
let curSpace = localStorage.getItem("xk_space")||"default";
|
|
195
188
|
const spaceLabel=s=>s.startsWith("/")?("📁 "+(s.split("/").filter(Boolean).pop()||s)):s;
|
|
196
189
|
function renderSpaces(){ $("#space").innerHTML = spaces.map(s=>`<option value="${esc(s)}" ${s===curSpace?"selected":""}>${esc(spaceLabel(s))}</option>`).join(""); }
|
|
197
|
-
|
|
190
|
+
function refreshAll(){ $("#fview").style.display="none"; wbSub=""; loadHistory(); loadFiles(); }
|
|
191
|
+
$("#space").onchange = () => { curSpace=$("#space").value; localStorage.setItem("xk_space",curSpace); $("#current").innerHTML=""; refreshAll(); };
|
|
198
192
|
$("#newspace").onclick = () => {
|
|
199
193
|
const raw=(prompt(LOCAL?"新專案:輸入名稱,或貼上一個真實資料夾的絕對路徑(本地模式會就地改該資料夾的檔,像 Claude Code)":"新專案名稱(英數/底線/連字號):")||"").trim();
|
|
200
194
|
if(!raw) return;
|
|
@@ -202,7 +196,7 @@ $("#newspace").onclick = () => {
|
|
|
202
196
|
if(!n) return;
|
|
203
197
|
if(!spaces.includes(n)) spaces.push(n);
|
|
204
198
|
curSpace=n; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace);
|
|
205
|
-
renderSpaces(); $("#current").innerHTML="";
|
|
199
|
+
renderSpaces(); $("#current").innerHTML=""; refreshAll();
|
|
206
200
|
};
|
|
207
201
|
renderSpaces();
|
|
208
202
|
|
|
@@ -220,7 +214,7 @@ async function fsGo(p){
|
|
|
220
214
|
}
|
|
221
215
|
function fsHome(){ fsGo(window._fsHome||null); }
|
|
222
216
|
function closeFs(){ $("#fsmodal").style.display="none"; }
|
|
223
|
-
function chooseFs(){ if(!fsPath) return; const p=fsPath; if(!spaces.includes(p)) spaces.push(p); curSpace=p; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace); renderSpaces(); closeFs(); $("#current").innerHTML="";
|
|
217
|
+
function chooseFs(){ if(!fsPath) return; const p=fsPath; if(!spaces.includes(p)) spaces.push(p); curSpace=p; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace); renderSpaces(); closeFs(); $("#current").innerHTML=""; refreshAll(); }
|
|
224
218
|
|
|
225
219
|
// 極簡 markdown 渲染(零依賴、可離線;夠用於 agent 產的報告)
|
|
226
220
|
function mdRender(src){
|
|
@@ -274,33 +268,23 @@ async function renderFile(urlFn, encPath, name, sel){
|
|
|
274
268
|
v.innerHTML=bar+body;
|
|
275
269
|
}
|
|
276
270
|
function viewFile(id, encPath, name){ return renderFile(taskFileUrl(id), encPath, name, "#fview"); }
|
|
277
|
-
function wbView(encPath, name){
|
|
271
|
+
function wbView(encPath, name){ return renderFile(wsFileUrl(curSpace), encPath, name, "#fview"); }
|
|
278
272
|
|
|
279
|
-
//
|
|
273
|
+
// 左欄檔案瀏覽器:逐層瀏覽 / 看(→ 主區 #fview) / 刪
|
|
280
274
|
function fmtSize(n){ return n<1024?n+" B":n<1048576?(n/1024).toFixed(1)+" KB":(n/1048576).toFixed(1)+" MB"; }
|
|
281
275
|
let wbSub="";
|
|
282
|
-
function wbNav(s){ wbSub=decodeURIComponent(s);
|
|
283
|
-
function wbUp(){ wbSub=wbSub.includes("/")?wbSub.slice(0,wbSub.lastIndexOf("/")):"";
|
|
284
|
-
async function
|
|
285
|
-
$("#wbview").style.display="none"; { const h=$("#wbhint"); if(h) h.style.display=""; }
|
|
276
|
+
function wbNav(s){ wbSub=decodeURIComponent(s); loadFiles(); }
|
|
277
|
+
function wbUp(){ wbSub=wbSub.includes("/")?wbSub.slice(0,wbSub.lastIndexOf("/")):""; loadFiles(); }
|
|
278
|
+
async function loadFiles(){
|
|
286
279
|
const r=await api("/v1/workspaces/files?ws="+encodeURIComponent(curSpace)+"&sub="+encodeURIComponent(wbSub)).then(r=>r.json()).catch(()=>({dirs:[],files:[]}));
|
|
287
|
-
|
|
288
|
-
let html=`<div class="loc">📂 ${esc(root)}${wbSub?" / "+esc(wbSub):""}</div>`;
|
|
280
|
+
let html=wbSub?`<div class="loc">📂 ${esc(wbSub)}</div>`:"";
|
|
289
281
|
if(wbSub) html+=`<div class="wbrow up" onclick="wbUp()"><span class="wbname">⬆ 上一層</span></div>`;
|
|
290
282
|
html+=(r.dirs||[]).map(d=>`<div class="wbrow" onclick="wbNav('${encodeURIComponent((wbSub?wbSub+'/':'')+d)}')"><span class="wbname">📁 ${esc(d)}/</span></div>`).join("");
|
|
291
283
|
html+=(r.files||[]).map(f=>{const full=(wbSub?wbSub+'/':'')+f.name;return `<div class="wbrow"><span class="wbname" onclick="wbView('${encodeURIComponent(full)}','${esc(f.name)}')">📄 ${esc(f.name)}</span><span class="wbmeta">${fmtSize(f.size)}</span><span class="wbdel" title="刪除" onclick="wbDelete('${encodeURIComponent(full)}','${esc(f.name)}')">🗑</span></div>`;}).join("");
|
|
292
|
-
if(!(r.dirs||[]).length && !(r.files||[]).length) html+=`<div class="empty">(${wbSub?"
|
|
284
|
+
if(!(r.dirs||[]).length && !(r.files||[]).length) html+=`<div class="empty">(${wbSub?"空資料夾":"還沒有檔案"})</div>`;
|
|
293
285
|
$("#wbfiles").innerHTML=html;
|
|
294
286
|
}
|
|
295
|
-
async function wbDelete(encPath, name){ if(!confirm("刪除 "+name+"?此動作無法復原。")) return; await api("/v1/workspaces/file?ws="+encodeURIComponent(curSpace)+"&path="+encodeURIComponent(decodeURIComponent(encPath)),{method:"DELETE"});
|
|
296
|
-
function switchTab(name){
|
|
297
|
-
const wish=name==="wish";
|
|
298
|
-
$("#wishview").style.display=wish?"":"none";
|
|
299
|
-
$("#workbench").style.display=wish?"none":"";
|
|
300
|
-
$("#tab-wish").classList.toggle("active",wish);
|
|
301
|
-
$("#tab-wb").classList.toggle("active",!wish);
|
|
302
|
-
if(!wish){ wbSub=""; loadWorkbench(); }
|
|
303
|
-
}
|
|
287
|
+
async function wbDelete(encPath, name){ if(!confirm("刪除 "+name+"?此動作無法復原。")) return; await api("/v1/workspaces/file?ws="+encodeURIComponent(curSpace)+"&path="+encodeURIComponent(decodeURIComponent(encPath)),{method:"DELETE"}); loadFiles(); }
|
|
304
288
|
|
|
305
289
|
let activeId = null, polling = null;
|
|
306
290
|
|
|
@@ -311,6 +295,7 @@ $("#go").onclick = async () => {
|
|
|
311
295
|
$("#go").disabled = false;
|
|
312
296
|
if (r.error) { alert(r.error); return; }
|
|
313
297
|
$("#goal").value = "";
|
|
298
|
+
$("#fview").style.display="none"; expandedLog=false;
|
|
314
299
|
activeId = r.taskId;
|
|
315
300
|
poll();
|
|
316
301
|
};
|
|
@@ -326,7 +311,7 @@ async function poll() {
|
|
|
326
311
|
const t = await api("/v1/tasks/"+activeId).then(r=>r.json());
|
|
327
312
|
liveTask = t;
|
|
328
313
|
renderCurrent(t);
|
|
329
|
-
if (t.status==="done" || t.status==="error") { loadHistory(); return; }
|
|
314
|
+
if (t.status==="done" || t.status==="error") { loadHistory(); loadFiles(); return; }
|
|
330
315
|
if (t.status==="needs-input") return; // 等使用者回答
|
|
331
316
|
polling = setTimeout(poll, 1200);
|
|
332
317
|
}
|
|
@@ -348,7 +333,6 @@ function renderCurrent(t) {
|
|
|
348
333
|
${t.status==="error"?`<div class="summary">⚠ ${esc(t.error)}</div>`:""}
|
|
349
334
|
${files.length?`<div class="files">📦 成品:${files.map(([f,k])=>`<span class="file ${k==="mod"?"mod":""}" onclick="viewFile('${t.taskId}','${encodeURIComponent(f)}','${esc(f)}')">${k==="mod"?"~":"+"} ${esc(f)}</span>`).join("")}</div>`:""}
|
|
350
335
|
${t.result&&t.result.workspaceDir?`<div class="loc">📂 檔案位置:<code title="點擊複製" onclick="navigator.clipboard&&navigator.clipboard.writeText('${esc(t.result.workspaceDir)}')">${esc(t.result.workspaceDir)}</code></div>`:""}
|
|
351
|
-
<div class="viewer" id="fview" style="display:none"></div>
|
|
352
336
|
${t.status==="done"?`<div class="followup">
|
|
353
337
|
<button class="ghost" onclick="toggleFollowup()">↳ 繼續/調整這個成果</button>
|
|
354
338
|
<div id="fubox" style="display:none;margin-top:8px">
|
|
@@ -385,7 +369,7 @@ async function loadHistory() {
|
|
|
385
369
|
}
|
|
386
370
|
async function openTask(id){ activeId=id; const t=await api("/v1/tasks/"+id).then(r=>r.json()); liveTask=t; renderCurrent(t); if(t.status==="running"||t.status==="queued"){poll();} window.scrollTo({top:0,behavior:"smooth"}); }
|
|
387
371
|
|
|
388
|
-
loadHistory();
|
|
372
|
+
loadHistory(); loadFiles();
|
|
389
373
|
</script>
|
|
390
374
|
</body>
|
|
391
375
|
</html>
|
package/src/kernel/agent-loop.js
CHANGED
|
@@ -50,6 +50,7 @@ export class Agent {
|
|
|
50
50
|
};
|
|
51
51
|
this.listeners = new Set();
|
|
52
52
|
this.streamFn = options.streamFn; // 必傳(xitto 都注入;缺則無法串流)
|
|
53
|
+
this.maxSteps = options.maxSteps || 80; // 單回合硬上限:防 agent 無限呼叫工具繞圈
|
|
53
54
|
this.getApiKey = options.getApiKey;
|
|
54
55
|
this.beforeToolCall = options.beforeToolCall;
|
|
55
56
|
this.afterToolCall = options.afterToolCall;
|
|
@@ -135,6 +136,7 @@ export class Agent {
|
|
|
135
136
|
const newMessages = [];
|
|
136
137
|
let pending = initialMessages.slice();
|
|
137
138
|
let firstTurn = true;
|
|
139
|
+
let steps = 0;
|
|
138
140
|
while (true) {
|
|
139
141
|
let hasMoreToolCalls = true;
|
|
140
142
|
while (hasMoreToolCalls || pending.length) {
|
|
@@ -149,6 +151,10 @@ export class Agent {
|
|
|
149
151
|
// abort 守衛:迴圈邊界若已中止,立即走 handleRunFailure(aborted)。
|
|
150
152
|
// 真實 provider 由 fetch signal 中止串流;此守衛確保 fake/工具觸發的 abort 也確定性收尾。
|
|
151
153
|
if (signal.aborted) throw new Error('Aborted');
|
|
154
|
+
// 硬上限:單回合工具呼叫太多 → 注入一句「別再用工具,直接用現有資訊作結」,逼它收尾
|
|
155
|
+
if (steps === this.maxSteps - 1) this._state.messages.push({ role: 'user', content: [{ type: 'text', text: `[系統] 已達工具呼叫上限(${this.maxSteps} 步)。請停止使用任何工具,直接根據目前已知資訊給出結論或說明卡在哪、缺什麼,然後結束。` }] });
|
|
156
|
+
if (steps >= this.maxSteps) { await this.emit({ type: 'agent_end', messages: newMessages }); return; }
|
|
157
|
+
steps++;
|
|
152
158
|
const message = await this.streamAssistant(signal);
|
|
153
159
|
newMessages.push(message);
|
|
154
160
|
if (message.stopReason === 'error' || message.stopReason === 'aborted') {
|
package/src/kernel/index.js
CHANGED
|
@@ -236,6 +236,7 @@ export function createKernel(pack, config = {}) {
|
|
|
236
236
|
pack.systemPrompt +
|
|
237
237
|
loadContextFiles(cwd, pack.contextFiles) + // 注入領域規範檔(CLAUDE.md 等)
|
|
238
238
|
'\n\n# 記憶與專案手冊\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) + '\n' + DEFAULT_PLAYBOOK_GUIDE + '\n' + DEFAULT_EPISODE_GUIDE +
|
|
239
|
+
'\n\n# 工作目錄\n你的工作目錄是:' + cwd + '\n所有檔案請用相對路徑寫在這個目錄內(如 report.md、data/x.csv)。除非使用者明確要求,絕對不要寫到此目錄之外(例如 /tmp、/app、/workspace、系統根目錄)——寫在外面使用者拿不到成品。' +
|
|
239
240
|
'\n\n# 成品與暫存\n' + DEFAULT_OUTPUT_GUIDE +
|
|
240
241
|
(memText ? `\n\n# 已記住的事實(跨 session)\n${memText}` : '') +
|
|
241
242
|
(pbText ? `\n\n# 專案手冊(這個專案怎麼做事,跨 session 累積)\n${pbText}` : '') +
|
|
@@ -438,6 +439,7 @@ export function createKernel(pack, config = {}) {
|
|
|
438
439
|
let lastRemaining = null;
|
|
439
440
|
let noProgress = 0;
|
|
440
441
|
let verifyErrors = 0;
|
|
442
|
+
let sameFeedback = 0;
|
|
441
443
|
for (let round = 1; round <= maxRounds; round++) {
|
|
442
444
|
opts.onRound?.({ round, maxRounds });
|
|
443
445
|
const r = await api.runTurn(instruction, { history, onEvent: opts.onEvent, onAgent: opts.onAgent });
|
|
@@ -459,7 +461,9 @@ export function createKernel(pack, config = {}) {
|
|
|
459
461
|
}
|
|
460
462
|
verifyErrors = 0;
|
|
461
463
|
const rem = normalizeFeedback(v.remaining);
|
|
462
|
-
|
|
464
|
+
// 驗收回饋重複 = agent 在繞圈(即使一直有動作也沒朝驗收要求收斂,如查不到的資訊一直換來源)→ 連 2 次相同就停,別空轉到上限
|
|
465
|
+
if (rem && rem === lastRemaining) { if (++sameFeedback >= 2) return { done: false, stalled: true, rounds: round, history }; }
|
|
466
|
+
else sameFeedback = 0;
|
|
463
467
|
lastRemaining = rem;
|
|
464
468
|
instruction = `目標尚未達成。驗收回饋:${v.remaining}\n請繼續完成目標:${goal}`;
|
|
465
469
|
}
|
|
@@ -30,6 +30,9 @@ const SYSTEM_PROMPT = [
|
|
|
30
30
|
export function createCodingPack({ cwd = process.cwd() } = {}) {
|
|
31
31
|
const readFiles = new Set(); // 已 read 過的絕對路徑(read 工具寫入、read-before-edit 守衛讀取)
|
|
32
32
|
const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
|
|
33
|
+
// 寫檔限制在工作目錄內:逃逸 cwd(如 /tmp、/app)回 null。讀檔不限制。
|
|
34
|
+
const within = (p) => { const full = abs(p); const r = relative(cwd, full); return (r === '' || (!r.startsWith('..') && !isAbsolute(r))) ? full : null; };
|
|
35
|
+
const escapeErr = (path) => txt({ error: `只能寫在工作目錄內:${cwd}`, hint: '請用相對路徑(如 report.md)', path });
|
|
33
36
|
const bg = createBackgroundTools(cwd); // bash_bg / bash_output / bash_kill
|
|
34
37
|
|
|
35
38
|
const readTool = {
|
|
@@ -64,7 +67,7 @@ export function createCodingPack({ cwd = process.cwd() } = {}) {
|
|
|
64
67
|
name: 'write', label: '寫檔', description: '建立或覆寫檔案', mutating: true,
|
|
65
68
|
parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
|
|
66
69
|
execute: async (_id, { path, content }) => {
|
|
67
|
-
const p =
|
|
70
|
+
const p = within(path); if (!p) return escapeErr(path);
|
|
68
71
|
writeFileSync(p, content ?? '', 'utf8');
|
|
69
72
|
readFiles.add(p); // 寫過即視為已知內容
|
|
70
73
|
return txt({ written: path, bytes: Buffer.byteLength(content ?? '') });
|
|
@@ -76,7 +79,7 @@ export function createCodingPack({ cwd = process.cwd() } = {}) {
|
|
|
76
79
|
description: '把檔案中的 oldText 換成 newText。oldText 必須唯一(出現多次會失敗,請加上下文;或設 replaceAll:true 全部取代)。',
|
|
77
80
|
parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' }, replaceAll: { type: 'boolean' } }, required: ['path', 'oldText', 'newText'] },
|
|
78
81
|
execute: async (_id, { path, oldText, newText, replaceAll }) => {
|
|
79
|
-
const p =
|
|
82
|
+
const p = within(path); if (!p) return escapeErr(path);
|
|
80
83
|
if (!existsSync(p)) return txt({ error: '檔案不存在', path });
|
|
81
84
|
const before = readFileSync(p, 'utf8');
|
|
82
85
|
const occurrences = before.split(oldText).length - 1;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// general pack — 通用自主 agent。廣的 system prompt + 檔案/shell/web 工具。
|
|
2
2
|
// 搭配 kernel 的 runGoal(目標循環)+ 子 agent + MCP,即為「給目標、自己做到完成」的通用 agent。
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
-
import { isAbsolute, join, extname } from 'node:path';
|
|
4
|
+
import { isAbsolute, join, extname, relative } from 'node:path';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import { createGrepTool, createGlobTool } from '../shared/code-nav.js';
|
|
7
7
|
import { createWebFetchTool, createWebSearchTool, createHttpTool } from '../shared/web-tools.js';
|
|
@@ -24,6 +24,9 @@ const SYSTEM_PROMPT = [
|
|
|
24
24
|
export function createGeneralPack({ cwd = process.cwd() } = {}) {
|
|
25
25
|
const readFiles = new Set();
|
|
26
26
|
const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
|
|
27
|
+
// 寫檔限制在工作目錄內:回解析後路徑,逃逸 cwd(如 /tmp、/app)回 null。讀檔不限制。
|
|
28
|
+
const within = (p) => { const full = abs(p); const r = relative(cwd, full); return (r === '' || (!r.startsWith('..') && !isAbsolute(r))) ? full : null; };
|
|
29
|
+
const escapeErr = (path) => txt({ error: `只能寫在工作目錄內:${cwd}`, hint: '請用相對路徑(如 report.md),不要寫到工作區之外', path });
|
|
27
30
|
|
|
28
31
|
const read = {
|
|
29
32
|
name: 'read', label: '讀檔', description: '讀取檔案內容', readOnly: true,
|
|
@@ -38,12 +41,12 @@ export function createGeneralPack({ cwd = process.cwd() } = {}) {
|
|
|
38
41
|
const write = {
|
|
39
42
|
name: 'write', label: '寫檔', description: '建立或覆寫檔案', mutating: true,
|
|
40
43
|
parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
|
|
41
|
-
execute: async (_id, { path, content }) => { const p =
|
|
44
|
+
execute: async (_id, { path, content }) => { const p = within(path); if (!p) return escapeErr(path); writeFileSync(p, content ?? '', 'utf8'); readFiles.add(p); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
|
|
42
45
|
};
|
|
43
46
|
const edit = {
|
|
44
47
|
name: 'edit', label: '編輯', description: '把檔案中的 oldText 換成 newText', mutating: true,
|
|
45
48
|
parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['path', 'oldText', 'newText'] },
|
|
46
|
-
execute: async (_id, { path, oldText, newText }) => { const p =
|
|
49
|
+
execute: async (_id, { path, oldText, newText }) => { const p = within(path); if (!p) return escapeErr(path); if (!existsSync(p)) return txt({ error: '檔案不存在', path }); const b = readFileSync(p, 'utf8'); if (!b.includes(oldText)) return txt({ error: 'oldText 未找到', path }); writeFileSync(p, b.replace(oldText, newText), 'utf8'); return txt({ edited: path }); },
|
|
47
50
|
};
|
|
48
51
|
const bash = {
|
|
49
52
|
name: 'bash', label: 'bash', description: '執行 shell 命令(可選 timeout 秒數,預設 120)', mutating: true, sandboxable: true,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 共用檔案/shell 工具(read/ls/write/edit/bash)+ read-before-edit 守衛。
|
|
2
2
|
// 多個 pack(coding/devops…)共用;read 附行號、edit 唯一性檢查、bash 用 spawnSync 捕捉 stderr。
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
-
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
|
|
7
7
|
const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
|
|
@@ -13,6 +13,9 @@ const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s
|
|
|
13
13
|
export function createFsTools(cwd) {
|
|
14
14
|
const readFiles = new Set();
|
|
15
15
|
const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
|
|
16
|
+
// 寫檔限制在工作目錄內:逃逸 cwd(如 /tmp、/app)回 null。讀檔不限制。
|
|
17
|
+
const within = (p) => { const full = abs(p); const r = relative(cwd, full); return (r === '' || (!r.startsWith('..') && !isAbsolute(r))) ? full : null; };
|
|
18
|
+
const escapeErr = (path) => txt({ error: `只能寫在工作目錄內:${cwd}`, hint: '請用相對路徑(如 report.md)', path });
|
|
16
19
|
|
|
17
20
|
const read = {
|
|
18
21
|
name: 'read', label: '讀檔', readOnly: true,
|
|
@@ -38,14 +41,14 @@ export function createFsTools(cwd) {
|
|
|
38
41
|
const write = {
|
|
39
42
|
name: 'write', label: '寫檔', mutating: true, description: '建立或覆寫檔案',
|
|
40
43
|
parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
|
|
41
|
-
execute: async (_id, { path, content }) => { const p =
|
|
44
|
+
execute: async (_id, { path, content }) => { const p = within(path); if (!p) return escapeErr(path); writeFileSync(p, content ?? '', 'utf8'); readFiles.add(p); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
|
|
42
45
|
};
|
|
43
46
|
const edit = {
|
|
44
47
|
name: 'edit', label: '編輯', mutating: true,
|
|
45
48
|
description: '把 oldText 換成 newText。oldText 須唯一(多次出現需 replaceAll)。',
|
|
46
49
|
parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' }, replaceAll: { type: 'boolean' } }, required: ['path', 'oldText', 'newText'] },
|
|
47
50
|
execute: async (_id, { path, oldText, newText, replaceAll }) => {
|
|
48
|
-
const p =
|
|
51
|
+
const p = within(path); if (!p) return escapeErr(path);
|
|
49
52
|
if (!existsSync(p)) return txt({ error: '檔案不存在', path });
|
|
50
53
|
const before = readFileSync(p, 'utf8');
|
|
51
54
|
const n = before.split(oldText).length - 1;
|