@openchambery/web 1.19.13 → 1.19.15-beta.1
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/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
- package/dist/assets/AssistantView-D5rEZ-Ss.js +2 -0
- package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-DaB9Sb7S.js} +1 -1
- package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
- package/dist/assets/Icon-D1BcR8Tz.js +1 -0
- package/dist/assets/MarkdownRenderer-emLeoNjI.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-BWCAPlqn.js} +3 -3
- package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-BD_NFKUv.js} +2 -2
- package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
- package/dist/assets/MobileShareBridge-Bk2fdkJk.js +4 -0
- package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
- package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-rs49_K1E.js} +1 -1
- package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-bIWeMLVx.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
- package/dist/assets/SettingsView-b8LUjoad.js +25 -0
- package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-D8JFlPdA.js} +1 -1
- package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-DCd-Godi.js} +1 -1
- package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-B50L_nUP.js} +1 -1
- package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-BWtCaTMe.js} +1 -1
- package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
- package/dist/assets/{clipboard-CUasgPiF.js → clipboard-GUUNlWIv.js} +1 -1
- package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
- package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
- package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
- package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
- package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
- package/dist/assets/{dialog-BwQ3rY_x.js → dialog-DhMGbF_B.js} +1 -1
- package/dist/assets/{es-CRjOwvYM.js → es-BQb6I8gl.js} +2 -2
- package/dist/assets/fileMentionTouchSelection-DKYkoypI.js +4 -0
- package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
- package/dist/assets/{fr-D9taEN9L.js → fr-BjGouC_l.js} +2 -2
- package/dist/assets/{gitApi-DtPf-h-O.js → gitApi-D4PyYgLZ.js} +3 -3
- package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
- package/dist/assets/insertionBoundaries-Bnnx03Wj.js +1 -0
- package/dist/assets/{ja-CYQ5aoWp.js → ja-Cw9zliYY.js} +2 -2
- package/dist/assets/{ko-BwASV0Q0.js → ko-Dyz0Dk9b.js} +2 -2
- package/dist/assets/main-BRiQTNFX.js +169 -0
- package/dist/assets/{main-Ci3JmtpW.js → main-CnY0LWNc.js} +2 -2
- package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
- package/dist/assets/miniChat-B0pw_mUs.js +2 -0
- package/dist/assets/mobile-BKHmxwXz.js +2 -0
- package/dist/assets/{multirun-CmaTeZON.js → multirun-BQKqVfFT.js} +1 -1
- package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
- package/dist/assets/{pl-DUI1vPil.js → pl-C7EY-dTH.js} +1 -1
- package/dist/assets/platform-DQ5_LNKO.js +2 -0
- package/dist/assets/{projectMeta-cw9nrwpr.js → projectMeta-BvG8sdtC.js} +2 -2
- package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
- package/dist/assets/renderElectronMiniChatApp-E5a0yy7E.js +2 -0
- package/dist/assets/renderMobileApp-DT0L4Ah6.js +28 -0
- package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-utWzrDwa.js} +1 -1
- package/dist/assets/runtimeEndpointReset-DE6lwHyg.js +147 -0
- package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
- package/dist/assets/sessionLookup-12zVOR0J.js +1 -0
- package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
- package/dist/assets/src-CFUi4riW.css +2 -0
- package/dist/assets/{store-CSnH_QXM.js → store-ClnJdJSj.js} +3 -3
- package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
- package/dist/assets/{textarea-BfNtUdJE.js → textarea-B9CmN13Z.js} +1 -1
- package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
- package/dist/assets/{uk-C1FYZlmA.js → uk-DgG8v2md.js} +2 -2
- package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
- package/dist/assets/{useAppFontEffects-BDFUND4v.js → useAppFontEffects-BhYuP4L2.js} +43 -44
- package/dist/assets/useDesktopWindowControlsLayout-DPNMRkrI.js +1 -0
- package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
- package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BYdpuwL7.js +413 -0
- package/dist/assets/useSessionAutoCleanup-CADZCvZL.js +2 -0
- package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-CuG88Lsj.js} +1 -1
- package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
- package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-C6OFKjeT.js} +2 -2
- package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-BVP99aMr.js} +2 -2
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- package/package.json +1 -1
- package/server/index.js +1 -0
- package/server/lib/assistants/DOCUMENTATION.md +120 -22
- package/server/lib/assistants/assign.js +117 -12
- package/server/lib/assistants/assign.test.js +79 -0
- package/server/lib/assistants/bubbles.js +1 -1
- package/server/lib/assistants/contact-store.js +520 -11
- package/server/lib/assistants/contact-store.test.js +362 -12
- package/server/lib/assistants/contact-tools.js +305 -54
- package/server/lib/assistants/contact-tools.test.js +239 -7
- package/server/lib/assistants/contracts.js +31 -0
- package/server/lib/assistants/harness.js +295 -132
- package/server/lib/assistants/harness.test.js +662 -10
- package/server/lib/assistants/pi-tools.js +2 -0
- package/server/lib/assistants/pi-tools.test.js +8 -0
- package/server/lib/assistants/routes.js +3 -0
- package/server/lib/assistants/service.js +861 -79
- package/server/lib/assistants/service.test.js +1234 -8
- package/server/lib/llm/DOCUMENTATION.md +10 -3
- package/server/lib/llm/completions.js +4 -0
- package/server/lib/llm/completions.test.js +3 -0
- package/server/lib/llm/generate.js +57 -11
- package/server/lib/llm/generate.test.js +92 -0
- package/server/lib/opencode/feature-routes-runtime.js +5 -0
- package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
- package/dist/assets/Icon-CkzzhaLr.js +0 -1
- package/dist/assets/MarkdownRenderer-CLQ4Z8Z0.js +0 -2
- package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
- package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
- package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
- package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
- package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
- package/dist/assets/main-BjeOhdcG.js +0 -169
- package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
- package/dist/assets/mobile-BVMNnPK2.js +0 -2
- package/dist/assets/platform-DzsGqlGW.js +0 -2
- package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
- package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
- package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
- package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
- package/dist/assets/sessionLookup-Imc995rW.js +0 -1
- package/dist/assets/src-AA-5nXsF.css +0 -2
- package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
- package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
- package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
|
@@ -21,9 +21,15 @@ import {
|
|
|
21
21
|
NEW_CONVERSATION_CONFIRM_BUBBLE,
|
|
22
22
|
NEW_CONVERSATION_TOOL_NAME,
|
|
23
23
|
parseContactToolCalls,
|
|
24
|
+
READ_SESSION_TOOL_NAME,
|
|
24
25
|
SCHEDULE_TASK_TOOL_NAME,
|
|
26
|
+
STOP_SESSION_TOOL_NAME,
|
|
27
|
+
STEER_SESSION_TOOL_NAME,
|
|
28
|
+
ARCHIVE_SESSION_TOOL_NAME,
|
|
29
|
+
DELETE_SESSION_TOOL_NAME,
|
|
25
30
|
stripContactToolFences,
|
|
26
31
|
UPDATE_DEFAULT_PROMPT_TOOL_NAME,
|
|
32
|
+
WATCH_SESSION_TOOL_NAME,
|
|
27
33
|
} from './contact-tools.js';
|
|
28
34
|
import {
|
|
29
35
|
createPiCodingRuntime,
|
|
@@ -79,19 +85,75 @@ function createAssistantMessageEventStream() {
|
|
|
79
85
|
};
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
const CONTACT_LANGUAGE_NAMES = {
|
|
89
|
+
en: 'English',
|
|
90
|
+
fr: 'French',
|
|
91
|
+
'zh-CN': 'Simplified Chinese',
|
|
92
|
+
'zh-TW': 'Traditional Chinese',
|
|
93
|
+
uk: 'Ukrainian',
|
|
94
|
+
es: 'Spanish',
|
|
95
|
+
'pt-BR': 'Brazilian Portuguese',
|
|
96
|
+
ko: 'Korean',
|
|
97
|
+
pl: 'Polish',
|
|
98
|
+
ja: 'Japanese',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const CONTACT_LANGUAGE_FALLBACK = "the user's interface language";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Map a UI locale code to a display name for the system prompt. Unknown or
|
|
105
|
+
* missing values fall back to a neutral phrase so `{{LANGUAGE}}` never leaks.
|
|
106
|
+
* Whitelisted only: arbitrary client text must not reach the system prompt.
|
|
107
|
+
*/
|
|
108
|
+
export const resolveContactLanguage = (value) => {
|
|
109
|
+
if (typeof value !== 'string') return CONTACT_LANGUAGE_FALLBACK;
|
|
110
|
+
const code = value.trim();
|
|
111
|
+
if (!code) return CONTACT_LANGUAGE_FALLBACK;
|
|
112
|
+
if (CONTACT_LANGUAGE_NAMES[code]) return CONTACT_LANGUAGE_NAMES[code];
|
|
113
|
+
const match = Object.keys(CONTACT_LANGUAGE_NAMES).find((key) => key.toLowerCase() === code.toLowerCase());
|
|
114
|
+
return match ? CONTACT_LANGUAGE_NAMES[match] : CONTACT_LANGUAGE_FALLBACK;
|
|
115
|
+
};
|
|
116
|
+
|
|
82
117
|
export const CONTACT_SYSTEM_PROMPT = [
|
|
83
|
-
"You are OpenChamber's in-app assistant — a personable contact
|
|
118
|
+
"You are OpenChamber's in-app assistant — a personable contact focused on understanding the user, communicating naturally, coordinating work, and following up on results.",
|
|
119
|
+
'For substantive tasks, default to finding the relevant existing OpenCode workspace and handing the task to a worker session with assign_session. Carry the user goal, relevant context, constraints, and verification expectations into that session; follow its results and explain the outcome in normal conversation.',
|
|
120
|
+
'Handle simple questions, file lookups, and small, clearly scoped configuration changes directly when their scope and verification are straightforward. As a task grows into implementation, multi-step debugging, or broad changes, hand it to a worker session. An explicit user request to handle a bounded task yourself can use the direct tools.',
|
|
84
121
|
'Reply in short chat bubbles: a few sentences each, separated by a blank line.',
|
|
85
122
|
'Talk like a person in the user\'s language. One short spoken bubble at a time — never a wall of paragraphs.',
|
|
123
|
+
'Always reply in {{LANGUAGE}} — the user\'s current interface language — even when the user writes in another language, unless they explicitly ask for a different one.',
|
|
86
124
|
'Never write chain-of-thought, plans, tool names, or English narration of what you will do. The user never sees thinking.',
|
|
87
125
|
'Do not expose tool traces, Activity, or editor actions.',
|
|
88
126
|
'You have bash, read, write, and edit in the working directory. Use them for pwd, files, and shell. Never say you have no terminal or cannot read files. Ignore any temporary generator workspace in the environment.',
|
|
89
|
-
'Understand natural language in any language, including Chinese: 开新对话 / 清除记忆 means new_conversation (LLM memory only, chat history stays), 清空聊天记录 means clear_chat_history (delete transcript), 找项目 means list_projects, 现有对话 means list_sessions, 查看助手设定 / 默认提示词 means get_assistant_settings (pass to="Name" for another assistant), 改默认提示词 / 设置人设 / 改某助手的默认提示词 means update_default_prompt (persists that assistant\'s settings, later turns only; pass to="OpenCode 配置助手" to edit another contact without changing this one), 建助理 means create_assistant, 建会话 / 开个新会话 means assign_session, 排定时任务 means schedule_task, 给 X 说一声 means message_assistant, 发卡片 means emit a card via those tools — never ask the user to type /card or /dm.',
|
|
90
|
-
'You receive the registered project catalog every turn.
|
|
91
|
-
'
|
|
92
|
-
'
|
|
127
|
+
'Understand natural language in any language, including Chinese: 开新对话 / 清除记忆 means new_conversation (LLM memory only, chat history stays), 清空聊天记录 means clear_chat_history (delete transcript), 找项目 means list_projects, 现有对话 means list_sessions, 查看助手设定 / 默认提示词 means get_assistant_settings (pass to="Name" for another assistant), 改默认提示词 / 设置人设 / 改某助手的默认提示词 means update_default_prompt (persists that assistant\'s settings, later turns only; pass to="OpenCode 配置助手" to edit another contact without changing this one), 建助理 means create_assistant, 建会话 / 开个新会话 / 继续会话 means assign_session, 监听会话 means watch_session, 停止/取消/打断会话 means stop_session, 插话 means steer_session, 归档会话 means archive_session, 删除会话 means delete_session, 排定时任务 means schedule_task, 给 X 说一声 means message_assistant, 发卡片 means emit a card via those tools — never ask the user to type /card or /dm.',
|
|
128
|
+
'You receive the registered project catalog every turn. Match the user goal against project labels and paths and existing OpenCode conversations via list_sessions. Start in the user-configured working directory; managed contacts start in the current server user home directory and can inspect user-global directories with read/bash. When the starting directory has no relevant session, walk its parent directories to find the nearest relevant existing OpenCode workspace using session directory metadata, then search that workspace for the requested project or conversation. Use an unscoped list_sessions lookup when broader context is needed. Prefer the user-configured directory and explicit session context; ask a short question when several relevant targets remain. Keep discovery bounded and use actual tool results as evidence.',
|
|
129
|
+
'An empty registered catalog still permits looking up existing sessions and inspecting the working directory. Resolve the existing workspace first. Assignment requires a registered project: when the resolved workspace needs registration, explain that specific prerequisite and ask the user to register it in Settings. Workspace discovery should preserve the user existing directory structure.',
|
|
130
|
+
'For substantive work, match the workspace, inspect relevant existing chats with list_sessions, then create a worker through assign_session with projectPath or continue the relevant conversation with sessionID plus the task prompt (omit model args to keep the prior worker model). File and shell tools support discovery and the small-task exception. To only listen without prompting when asked (监听/watch/monitor), watch_session with sessionID — a plain @session:id mention alone is not a watch request. To abort a running session, stop_session with sessionID. Optional worker model via providerID/modelID/model from the connected catalog — that does not change this contact. Current-turn user attachments are server-forwarded on assign. One successful assign_session or watch_session ends the turn — do not call it again.',
|
|
131
|
+
'A reply without the tool call does nothing. Never say 已创建, created, scheduled, opened, watched, or stopped unless the tool already returned success.',
|
|
93
132
|
].join(' ');
|
|
94
133
|
|
|
134
|
+
// A workspace response must explicitly finish or ask for missing input. Plain
|
|
135
|
+
// progress prose is not a final answer and must not silently end the pi loop.
|
|
136
|
+
const WORKSPACE_RESPONSE_PROTOCOL = [
|
|
137
|
+
'Workspace response protocol: continue using openchamber-tool calls until the task is handed to a worker session, or the small direct task and its verification are done. A successful assign_session ends this turn and worker completion is reported through the session follow-up.',
|
|
138
|
+
'You may batch independent coding calls, but they execute sequentially in the order supplied. Wait for results before using their output. Send OpenChamber operation tools one at a time.',
|
|
139
|
+
'Reading a skill loads instructions. For substantive work, pass relevant instructions and context to the worker session; for a small direct task, execute the applicable steps and verify the result before completing.',
|
|
140
|
+
'To finish, reply with only an openchamber-final JSON fence: ```openchamber-final\n{"status":"complete","text":"Your final answer in the user language"}\n```.',
|
|
141
|
+
'Use status "blocked" with the missing information or real blocker when you cannot proceed. Questions and ordinary conversation also use this final format; they do not require a tool call.',
|
|
142
|
+
'Do not use a final declaration for a progress update or promise to act later. Never invent tool results, User messages, or future assistant turns. Only supplied tool-result records are execution evidence.',
|
|
143
|
+
].join('\n');
|
|
144
|
+
|
|
145
|
+
const parseFinalResponse = (text) => {
|
|
146
|
+
const match = text.trim().match(/^```openchamber-final\s*([\s\S]*?)```$/u);
|
|
147
|
+
if (!match) return null;
|
|
148
|
+
try {
|
|
149
|
+
const value = JSON.parse(match[1]);
|
|
150
|
+
if (!value || !['complete', 'blocked'].includes(value.status) || typeof value.text !== 'string' || !value.text.trim()) return null;
|
|
151
|
+
return { status: value.status, text: value.text.trim() };
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
95
157
|
const emptyUsage = () => ({
|
|
96
158
|
input: 0,
|
|
97
159
|
output: 0,
|
|
@@ -148,14 +210,69 @@ const completionFileParts = (value) => (Array.isArray(value) ? value : [])
|
|
|
148
210
|
/** Fence start (openchamber-tool or generic ```) — stop live bubble deltas. */
|
|
149
211
|
const CONTACT_FENCE_START = /```/;
|
|
150
212
|
|
|
213
|
+
/** Tool confirms that are themselves the user-facing bubble (no card). */
|
|
214
|
+
const TOOL_TEXT_BUBBLE_TOOLS = new Set([
|
|
215
|
+
STOP_SESSION_TOOL_NAME,
|
|
216
|
+
STEER_SESSION_TOOL_NAME,
|
|
217
|
+
ARCHIVE_SESSION_TOOL_NAME,
|
|
218
|
+
DELETE_SESSION_TOOL_NAME,
|
|
219
|
+
NEW_CONVERSATION_TOOL_NAME,
|
|
220
|
+
CLEAR_CHAT_HISTORY_TOOL_NAME,
|
|
221
|
+
UPDATE_DEFAULT_PROMPT_TOOL_NAME,
|
|
222
|
+
GET_ASSISTANT_SETTINGS_TOOL_NAME,
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
/** Card / side-effect tools: never paint English toolText into the transcript. */
|
|
226
|
+
const CARD_SIDE_EFFECT_TOOLS = new Set([
|
|
227
|
+
ASSIGN_SESSION_TOOL_NAME,
|
|
228
|
+
WATCH_SESSION_TOOL_NAME,
|
|
229
|
+
STOP_SESSION_TOOL_NAME,
|
|
230
|
+
CREATE_ASSISTANT_TOOL_NAME,
|
|
231
|
+
SCHEDULE_TASK_TOOL_NAME,
|
|
232
|
+
MESSAGE_ASSISTANT_TOOL_NAME,
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
/** Lookup payloads for the model only — never paint quoted JSON as a user bubble. */
|
|
236
|
+
const MODEL_ONLY_LOOKUP_TOOLS = new Set([
|
|
237
|
+
READ_SESSION_TOOL_NAME,
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
const assistantTextParts = (message) => (Array.isArray(message?.content) ? message.content : [])
|
|
241
|
+
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
242
|
+
.map((part) => part.text)
|
|
243
|
+
.join('');
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Streamed assistant bubbles already present in this turn's Agent messages.
|
|
247
|
+
* Spoken preambles (tool turns) and final assistant text share splitContactBubbles.
|
|
248
|
+
* Independent messages keep duplicate text. Tool-result confirms are not streamed here.
|
|
249
|
+
*/
|
|
250
|
+
export function projectStreamedContactTurnBubbles(messages, turnStart = 0) {
|
|
251
|
+
const start = Number.isFinite(Number(turnStart)) ? Math.max(0, Number(turnStart)) : 0;
|
|
252
|
+
const list = Array.isArray(messages) ? messages.slice(start) : [];
|
|
253
|
+
const bubbles = [];
|
|
254
|
+
for (const message of list) {
|
|
255
|
+
if (message?.role !== 'assistant') continue;
|
|
256
|
+
const parts = Array.isArray(message.content) ? message.content : [];
|
|
257
|
+
const hasTool = parts.some((part) => part?.type === 'toolCall');
|
|
258
|
+
const text = assistantTextParts(message).trim();
|
|
259
|
+
if (!text) continue;
|
|
260
|
+
if (hasTool && !isContactSpokenPreamble(text)) continue;
|
|
261
|
+
for (const bubble of splitContactBubbles(text)) bubbles.push(bubble);
|
|
262
|
+
}
|
|
263
|
+
return bubbles;
|
|
264
|
+
}
|
|
265
|
+
|
|
151
266
|
/**
|
|
152
267
|
* Incremental bubble stream from live token deltas.
|
|
153
268
|
* - done:false = token increment for the active bubble
|
|
154
269
|
* - done:true = that bubble is complete (split off or finalized)
|
|
270
|
+
* - baseIndex offsets indices so later completions continue the turn sequence
|
|
155
271
|
* Stops when a markdown fence starts so tool JSON never leaks.
|
|
156
272
|
*/
|
|
157
|
-
export function createContactBubbleDeltaTracker(onBubbleDelta) {
|
|
273
|
+
export function createContactBubbleDeltaTracker(onBubbleDelta, { baseIndex = 0 } = {}) {
|
|
158
274
|
const emit = typeof onBubbleDelta === 'function' ? onBubbleDelta : null;
|
|
275
|
+
const offset = Number.isFinite(Number(baseIndex)) ? Math.max(0, Number(baseIndex)) : 0;
|
|
159
276
|
let accumulated = '';
|
|
160
277
|
let completedCount = 0;
|
|
161
278
|
let activeEmitted = '';
|
|
@@ -165,7 +282,7 @@ export function createContactBubbleDeltaTracker(onBubbleDelta) {
|
|
|
165
282
|
const push = (index, delta, done) => {
|
|
166
283
|
if (!emit || (typeof delta === 'string' && delta.length === 0 && !done)) return;
|
|
167
284
|
emittedAny = true;
|
|
168
|
-
emit(index, delta, done);
|
|
285
|
+
emit(offset + index, delta, done);
|
|
169
286
|
};
|
|
170
287
|
|
|
171
288
|
const applyVisible = (visible) => {
|
|
@@ -192,6 +309,9 @@ export function createContactBubbleDeltaTracker(onBubbleDelta) {
|
|
|
192
309
|
get stopped() {
|
|
193
310
|
return stopped;
|
|
194
311
|
},
|
|
312
|
+
get baseIndex() {
|
|
313
|
+
return offset;
|
|
314
|
+
},
|
|
195
315
|
pushDelta(delta) {
|
|
196
316
|
if (stopped || typeof delta !== 'string' || !delta) return;
|
|
197
317
|
const next = accumulated + delta;
|
|
@@ -237,7 +357,12 @@ export function createContactStreamFn(createChatCompletion, {
|
|
|
237
357
|
onBubbleDelta = null,
|
|
238
358
|
globalEventHub = null,
|
|
239
359
|
bubbleGapMs = 0,
|
|
360
|
+
signal = null,
|
|
361
|
+
/** Fixed Agent message length before this contact turn (prior history only). */
|
|
362
|
+
turnMessageStart = 0,
|
|
240
363
|
} = {}) {
|
|
364
|
+
let callSequence = 0;
|
|
365
|
+
const turnStart = Number.isFinite(Number(turnMessageStart)) ? Math.max(0, Number(turnMessageStart)) : 0;
|
|
241
366
|
return (model, context) => {
|
|
242
367
|
const stream = createAssistantMessageEventStream();
|
|
243
368
|
const run = async () => {
|
|
@@ -257,10 +382,13 @@ export function createContactStreamFn(createChatCompletion, {
|
|
|
257
382
|
}];
|
|
258
383
|
}
|
|
259
384
|
if (message.role === 'assistant') {
|
|
260
|
-
const content = (message.content || [])
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
.
|
|
385
|
+
const content = (message.content || []).map((part) => {
|
|
386
|
+
if (part?.type === 'text') return part.text;
|
|
387
|
+
if (part?.type !== 'toolCall') return '';
|
|
388
|
+
return '```openchamber-tool\n' + JSON.stringify({
|
|
389
|
+
id: part.id, name: part.name, arguments: part.arguments,
|
|
390
|
+
}) + '\n```';
|
|
391
|
+
}).filter(Boolean).join('\n\n');
|
|
264
392
|
return content ? [{ role: 'assistant', content }] : [];
|
|
265
393
|
}
|
|
266
394
|
if (message.role === 'toolResult') {
|
|
@@ -268,7 +396,6 @@ export function createContactStreamFn(createChatCompletion, {
|
|
|
268
396
|
.filter((part) => part?.type === 'text')
|
|
269
397
|
.map((part) => part.text)
|
|
270
398
|
.join('');
|
|
271
|
-
if (!content) return [];
|
|
272
399
|
const toolName = typeof message.toolName === 'string' && message.toolName.trim()
|
|
273
400
|
? message.toolName.trim()
|
|
274
401
|
: 'result';
|
|
@@ -278,7 +405,7 @@ export function createContactStreamFn(createChatCompletion, {
|
|
|
278
405
|
const label = callId
|
|
279
406
|
? `OpenChamber tool result name=${toolName} call=${callId}`
|
|
280
407
|
: `OpenChamber tool result name=${toolName}`;
|
|
281
|
-
return [{ role: 'user', content: `${label}: ${content}` }];
|
|
408
|
+
return [{ role: 'user', content: `${label}: ${JSON.stringify({ isError: message.isError === true, content })}` }];
|
|
282
409
|
}
|
|
283
410
|
return [];
|
|
284
411
|
});
|
|
@@ -290,64 +417,97 @@ export function createContactStreamFn(createChatCompletion, {
|
|
|
290
417
|
if (lastUser && fallbackFiles.length > 0 && !Array.isArray(lastUser.parts)) {
|
|
291
418
|
lastUser.parts = fallbackFiles;
|
|
292
419
|
}
|
|
293
|
-
const bubbleTracker = createContactBubbleDeltaTracker(onBubbleDelta);
|
|
294
|
-
const result = await createChatCompletion({
|
|
295
|
-
body: {
|
|
296
|
-
model: `${model.provider === 'openchamber' ? '' : `${model.provider}/`}${model.id}`.replace(/^\//, '') || model.name,
|
|
297
|
-
providerID: typeof model.name === 'string' && model.name.includes('/')
|
|
298
|
-
? model.name.split('/')[0]
|
|
299
|
-
: undefined,
|
|
300
|
-
modelID: model.id,
|
|
301
|
-
messages,
|
|
302
|
-
},
|
|
303
|
-
onTextDelta: (delta) => {
|
|
304
|
-
if (typeof delta !== 'string' || !delta) return;
|
|
305
|
-
// Do not live-paint raw tokens: they are often chain-of-thought
|
|
306
|
-
// and would appear then vanish after parse/persist.
|
|
307
|
-
if (typeof onTextDelta === 'function') onTextDelta(delta);
|
|
308
|
-
},
|
|
309
|
-
globalEventHub,
|
|
310
|
-
});
|
|
311
|
-
const text = result?.completion?.choices?.[0]?.message?.content
|
|
312
|
-
?? result?.text
|
|
313
|
-
?? '';
|
|
314
420
|
const allowedNames = (context.tools || []).map((tool) => tool?.name).filter(Boolean);
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
421
|
+
const requiresFinal = allowedNames.some(isPiCodingToolName);
|
|
422
|
+
let text;
|
|
423
|
+
let parsed;
|
|
424
|
+
let finalText;
|
|
425
|
+
let rejectedCodingCalls = [];
|
|
426
|
+
// Repair only the rejected response: no tools have executed from it.
|
|
427
|
+
// Real results stay in messages, so already completed work is not replayed.
|
|
428
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
429
|
+
signal?.throwIfAborted();
|
|
430
|
+
const result = await createChatCompletion({
|
|
431
|
+
signal,
|
|
432
|
+
body: {
|
|
433
|
+
model: `${model.provider === 'openchamber' ? '' : `${model.provider}/`}${model.id}`.replace(/^\//, '') || model.name,
|
|
434
|
+
providerID: typeof model.name === 'string' && model.name.includes('/')
|
|
435
|
+
? model.name.split('/')[0]
|
|
436
|
+
: undefined,
|
|
437
|
+
modelID: model.id,
|
|
438
|
+
messages: [...messages],
|
|
439
|
+
},
|
|
440
|
+
onTextDelta: (delta) => {
|
|
441
|
+
if (typeof delta === 'string' && delta && typeof onTextDelta === 'function') onTextDelta(delta);
|
|
442
|
+
},
|
|
443
|
+
globalEventHub,
|
|
444
|
+
});
|
|
445
|
+
signal?.throwIfAborted();
|
|
446
|
+
text = result?.completion?.choices?.[0]?.message?.content ?? result?.text ?? '';
|
|
447
|
+
const finalResponse = parseFinalResponse(text);
|
|
448
|
+
finalText = finalResponse?.text ?? null;
|
|
449
|
+
// A final answer is opaque text; quoted tool examples in it cannot run.
|
|
450
|
+
parsed = finalText === null ? parseContactToolCalls(text, allowedNames) : { toolCalls: [], chatText: finalText };
|
|
451
|
+
const mixedBatch = parsed.toolCalls.length > 1 && parsed.toolCalls.some((call) => !isPiCodingToolName(call.name));
|
|
452
|
+
const malformedFinal = finalText === null && /```openchamber-final/u.test(parsed.chatText);
|
|
453
|
+
const skippedRejectedWork = rejectedCodingCalls.length > 0 && finalResponse?.status === 'complete';
|
|
454
|
+
const forgedTranscript = finalText === null && /(?:^|\n)\s*(?:(?:User|Assistant|Tool):|OpenChamber tool result name=)/iu.test(parsed.chatText);
|
|
455
|
+
const protocolError = parsed.protocolError
|
|
456
|
+
|| (forgedTranscript ? 'Do not simulate user messages, assistant turns, or tool results. Output only your next real tool call and wait for execution.' : '')
|
|
457
|
+
|| (skippedRejectedWork ? 'Previously rejected coding calls have not executed. Issue the remaining tool calls, or report a blocked outcome; do not declare them complete.' : '')
|
|
458
|
+
|| (mixedBatch ? 'Send OpenChamber operation tools one at a time, without other calls in the same response.' : '')
|
|
459
|
+
|| (malformedFinal ? 'The final declaration must be a single valid openchamber-final JSON fence with status and text, without other content.' : '')
|
|
460
|
+
|| (requiresFinal && parsed.toolCalls.length === 0 && finalText === null ? 'A workspace response needs an actual tool call or an explicit final declaration.' : '');
|
|
461
|
+
if (!protocolError) break;
|
|
462
|
+
if (attempt >= 2) throw new Error('Assistant response protocol failed after two corrections; the task was not confirmed complete.');
|
|
463
|
+
const rejected = parsed.toolCalls.filter((call) => isPiCodingToolName(call.name));
|
|
464
|
+
if (rejected.length > 0) rejectedCodingCalls = rejected.map(({ name, arguments: args }) => ({ name, arguments: args }));
|
|
465
|
+
// Never replay rejected prose: it may contain fabricated user/tool results.
|
|
466
|
+
messages.push(
|
|
467
|
+
{ role: 'user', content: `OpenChamber response protocol correction: ${protocolError} No tool calls from the rejected response executed. ${rejectedCodingCalls.length ? `Unexecuted proposed calls (not results): ${JSON.stringify(rejectedCodingCalls)}.` : ''} Continue the remaining work using the supplied real results; do not repeat completed operations. ${requiresFinal ? WORKSPACE_RESPONSE_PROTOCOL : 'Emit the corrected openchamber-tool call.'}` },
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
// Continue turn-global bubble indices from already-streamed turn messages.
|
|
471
|
+
const baseIndex = projectStreamedContactTurnBubbles(context.messages, turnStart).length;
|
|
472
|
+
const bubbleTracker = createContactBubbleDeltaTracker(onBubbleDelta, { baseIndex });
|
|
473
|
+
if (parsed.toolCalls.length > 0) {
|
|
474
|
+
const toolCalls = parsed.toolCalls.map((call) => ({
|
|
318
475
|
type: 'toolCall',
|
|
319
|
-
id: `call_${Date.now().toString(36)}`,
|
|
320
|
-
name:
|
|
321
|
-
arguments:
|
|
322
|
-
};
|
|
476
|
+
id: `call_${Date.now().toString(36)}_${++callSequence}`,
|
|
477
|
+
name: call.name,
|
|
478
|
+
arguments: call.arguments,
|
|
479
|
+
}));
|
|
323
480
|
const spoken = isContactSpokenPreamble(parsed.chatText) ? parsed.chatText.trim() : '';
|
|
481
|
+
const spokenBubbles = spoken ? splitContactBubbles(spoken) : [];
|
|
324
482
|
const content = spoken
|
|
325
|
-
? [{ type: 'text', text: spoken },
|
|
326
|
-
:
|
|
483
|
+
? [{ type: 'text', text: spoken }, ...toolCalls]
|
|
484
|
+
: toolCalls;
|
|
327
485
|
const partial = {
|
|
328
486
|
...assistantMessage(model, spoken, 'toolUse'),
|
|
329
487
|
content,
|
|
330
488
|
};
|
|
331
|
-
if (
|
|
489
|
+
if (spokenBubbles.length > 0) bubbleTracker.finish(spokenBubbles);
|
|
332
490
|
stream.push({ type: 'start', partial });
|
|
333
491
|
if (spoken) {
|
|
334
492
|
stream.push({ type: 'text_start', contentIndex: 0, partial });
|
|
335
493
|
stream.push({ type: 'text_delta', contentIndex: 0, delta: spoken, partial });
|
|
336
494
|
stream.push({ type: 'text_end', contentIndex: 0, content: spoken, partial });
|
|
337
495
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
496
|
+
toolCalls.forEach((toolCall, index) => {
|
|
497
|
+
const toolIndex = index + (spoken ? 1 : 0);
|
|
498
|
+
stream.push({ type: 'toolcall_start', contentIndex: toolIndex, partial });
|
|
499
|
+
stream.push({ type: 'toolcall_delta', contentIndex: toolIndex, delta: JSON.stringify(toolCall.arguments), partial });
|
|
500
|
+
stream.push({ type: 'toolcall_end', contentIndex: toolIndex, toolCall, partial });
|
|
501
|
+
});
|
|
342
502
|
stream.push({ type: 'done', reason: 'toolUse', message: partial });
|
|
343
503
|
stream.end(partial);
|
|
344
504
|
return;
|
|
345
505
|
}
|
|
346
|
-
const chatText =
|
|
506
|
+
const chatText = finalText ?? parsed.chatText;
|
|
347
507
|
const replyBubbles = splitContactBubbles(chatText);
|
|
348
508
|
if (bubbleGapMs > 0 && replyBubbles.length > 1) {
|
|
349
509
|
for (let index = 0; index < replyBubbles.length; index += 1) {
|
|
350
|
-
onBubbleDelta?.(index, replyBubbles[index], true);
|
|
510
|
+
onBubbleDelta?.(baseIndex + index, replyBubbles[index], true);
|
|
351
511
|
if (index < replyBubbles.length - 1) {
|
|
352
512
|
await new Promise((resolve) => setTimeout(resolve, bubbleGapMs));
|
|
353
513
|
}
|
|
@@ -363,7 +523,7 @@ export function createContactStreamFn(createChatCompletion, {
|
|
|
363
523
|
stream.push({ type: 'done', reason: 'stop', message: partial });
|
|
364
524
|
stream.end(partial);
|
|
365
525
|
} catch (error) {
|
|
366
|
-
const failed = assistantMessage(model, '', 'error', error?.message || 'upstream_error');
|
|
526
|
+
const failed = assistantMessage(model, '', signal?.aborted ? 'aborted' : 'error', error?.message || 'upstream_error');
|
|
367
527
|
stream.push({ type: 'error', reason: 'error', error: failed });
|
|
368
528
|
stream.end(failed);
|
|
369
529
|
}
|
|
@@ -378,22 +538,6 @@ const userMessageText = (message) => {
|
|
|
378
538
|
return (message?.content || []).map((part) => (typeof part?.text === 'string' ? part.text : '')).join('');
|
|
379
539
|
};
|
|
380
540
|
|
|
381
|
-
const assistantTextParts = (message) => (Array.isArray(message?.content) ? message.content : [])
|
|
382
|
-
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
383
|
-
.map((part) => part.text)
|
|
384
|
-
.join('');
|
|
385
|
-
|
|
386
|
-
const extractSpokenPreamble = (messages) => {
|
|
387
|
-
for (const message of Array.isArray(messages) ? messages : []) {
|
|
388
|
-
if (message?.role !== 'assistant') continue;
|
|
389
|
-
const parts = Array.isArray(message.content) ? message.content : [];
|
|
390
|
-
if (!parts.some((part) => part?.type === 'toolCall')) continue;
|
|
391
|
-
const text = assistantTextParts(message).trim();
|
|
392
|
-
if (isContactSpokenPreamble(text)) return text;
|
|
393
|
-
}
|
|
394
|
-
return '';
|
|
395
|
-
};
|
|
396
|
-
|
|
397
541
|
const extractAssistantText = (messages) => {
|
|
398
542
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
399
543
|
const message = messages[index];
|
|
@@ -425,65 +569,56 @@ const extractToolResultText = (messages) => {
|
|
|
425
569
|
return '';
|
|
426
570
|
};
|
|
427
571
|
|
|
428
|
-
/**
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
/** Card / side-effect tools: never paint English toolText into the transcript. */
|
|
437
|
-
const CARD_SIDE_EFFECT_TOOLS = new Set([
|
|
438
|
-
ASSIGN_SESSION_TOOL_NAME,
|
|
439
|
-
CREATE_ASSISTANT_TOOL_NAME,
|
|
440
|
-
SCHEDULE_TASK_TOOL_NAME,
|
|
441
|
-
MESSAGE_ASSISTANT_TOOL_NAME,
|
|
442
|
-
]);
|
|
443
|
-
|
|
444
|
-
const extractContactTurnOutcome = (messages, retried) => {
|
|
445
|
-
const list = Array.isArray(messages) ? messages : [];
|
|
446
|
-
let start = 0;
|
|
447
|
-
if (retried) {
|
|
448
|
-
for (let index = list.length - 1; index >= 0; index -= 1) {
|
|
449
|
-
if (list[index]?.role === 'user') {
|
|
450
|
-
start = index;
|
|
451
|
-
break;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
if (start === 0) {
|
|
455
|
-
const toolIndex = list.findIndex((message) => message?.role === 'toolResult');
|
|
456
|
-
if (toolIndex >= 0) start = toolIndex;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
const slice = list.slice(start);
|
|
572
|
+
/**
|
|
573
|
+
* Final turn bubbles reuse the same projection as live onBubbleDelta indices
|
|
574
|
+
* (every spoken preamble + final assistant text; independent messages keep
|
|
575
|
+
* duplicate text). Tool-result-only confirms (never streamed mid-turn) append
|
|
576
|
+
* after that published prefix. Reset collapses to a unique confirm separately.
|
|
577
|
+
*/
|
|
578
|
+
const extractContactTurnOutcome = (messages) => {
|
|
579
|
+
const slice = Array.isArray(messages) ? messages : [];
|
|
460
580
|
const cards = extractContactCardsFromMessages(slice);
|
|
461
581
|
const hasTool = contactTurnHasToolResult(slice);
|
|
462
|
-
|
|
582
|
+
// Same rules as live SSE indices — keep every independent spoken/final bubble.
|
|
583
|
+
const published = projectStreamedContactTurnBubbles(slice, 0);
|
|
463
584
|
const lastToolName = [...slice].reverse().find((message) => message?.role === 'toolResult')?.toolName;
|
|
464
585
|
const coding = isPiCodingToolName(lastToolName);
|
|
465
586
|
const toolText = extractToolResultText(slice);
|
|
466
587
|
const assistant = extractAssistantText(slice);
|
|
467
|
-
|
|
588
|
+
// Tool-result text is never projected mid-turn; append only when it is the
|
|
589
|
+
// user-facing confirm (confirm-only / list tools). Spoken + assistant text
|
|
590
|
+
// already live in `published`. Card/side-effect and read_session never paint
|
|
591
|
+
// English tool payloads.
|
|
592
|
+
let toolOnlyConfirm = '';
|
|
468
593
|
if (hasTool && !coding) {
|
|
469
594
|
if (TOOL_TEXT_BUBBLE_TOOLS.has(lastToolName)) {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
} else if (CARD_SIDE_EFFECT_TOOLS.has(lastToolName)) {
|
|
473
|
-
|
|
474
|
-
// Post-tool assistant text (terminate:false tools) may still surface when no spoken.
|
|
475
|
-
confirm = spoken ? '' : (assistant || '');
|
|
595
|
+
toolOnlyConfirm = toolText || '';
|
|
596
|
+
if (!toolOnlyConfirm && published.length === 0) toolOnlyConfirm = assistant || '';
|
|
597
|
+
} else if (CARD_SIDE_EFFECT_TOOLS.has(lastToolName) || MODEL_ONLY_LOOKUP_TOOLS.has(lastToolName)) {
|
|
598
|
+
toolOnlyConfirm = '';
|
|
476
599
|
} else {
|
|
477
600
|
// list_projects / list_sessions and other app tools keep toolText.
|
|
478
|
-
|
|
601
|
+
toolOnlyConfirm = toolText || '';
|
|
602
|
+
if (!toolOnlyConfirm && published.length === 0) toolOnlyConfirm = assistant || '';
|
|
479
603
|
}
|
|
480
|
-
} else {
|
|
481
|
-
|
|
604
|
+
} else if (hasTool && coding && published.length === 0) {
|
|
605
|
+
toolOnlyConfirm = toolText || '';
|
|
606
|
+
}
|
|
607
|
+
const bubbles = [...published];
|
|
608
|
+
if (toolOnlyConfirm) {
|
|
609
|
+
const confirmBubbles = splitContactBubbles(toolOnlyConfirm);
|
|
610
|
+
const trailing = bubbles.slice(-confirmBubbles.length);
|
|
611
|
+
const alreadySuffix = confirmBubbles.length > 0
|
|
612
|
+
&& trailing.length === confirmBubbles.length
|
|
613
|
+
&& trailing.every((item, index) => item === confirmBubbles[index]);
|
|
614
|
+
if (!alreadySuffix) bubbles.push(...confirmBubbles);
|
|
482
615
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
616
|
+
return {
|
|
617
|
+
text: bubbles.join('\n\n'),
|
|
618
|
+
bubbles,
|
|
619
|
+
cards,
|
|
620
|
+
hasTool,
|
|
621
|
+
};
|
|
487
622
|
};
|
|
488
623
|
|
|
489
624
|
/**
|
|
@@ -504,34 +639,45 @@ export async function runContactTurn({
|
|
|
504
639
|
tools = [],
|
|
505
640
|
projects = [],
|
|
506
641
|
connectedModels = [],
|
|
642
|
+
modelPreferences = null,
|
|
643
|
+
modelCatalogAvailable = true,
|
|
507
644
|
onTextDelta = null,
|
|
508
645
|
onBubbleDelta = null,
|
|
509
646
|
globalEventHub = null,
|
|
510
647
|
skillHomeDir,
|
|
648
|
+
language = '',
|
|
511
649
|
AgentImpl = Agent,
|
|
650
|
+
signal = null,
|
|
651
|
+
readOnly = false,
|
|
512
652
|
}) {
|
|
513
653
|
const providerID = assistant.providerID;
|
|
514
654
|
const modelID = assistant.modelID;
|
|
515
655
|
const cwd = resolveAssistantCwd(assistant);
|
|
516
656
|
const contactTools = Array.isArray(tools)
|
|
517
|
-
? tools.filter((tool) => tool && typeof tool.name === 'string' && !isPiCodingToolName(tool.name))
|
|
657
|
+
? tools.filter((tool) => tool && typeof tool.name === 'string' && !isPiCodingToolName(tool.name) && (!readOnly || ['list_projects', 'list_sessions', 'read_session', 'get_assistant_settings'].includes(tool.name)))
|
|
518
658
|
: [];
|
|
519
659
|
let runtime = null;
|
|
660
|
+
let removeAbortListener = null;
|
|
520
661
|
try {
|
|
521
|
-
|
|
662
|
+
signal?.throwIfAborted();
|
|
663
|
+
if (cwd && !readOnly) runtime = await createPiCodingRuntime(cwd, { homeDir: skillHomeDir });
|
|
522
664
|
const codingTools = Array.isArray(runtime?.tools) ? runtime.tools : [];
|
|
523
665
|
const systemPrompt = [
|
|
524
666
|
CONTACT_SYSTEM_PROMPT,
|
|
667
|
+
'Conversation history is a bounded recent window, not guaranteed complete memory. OpenChamber card context records contain actual session identifiers and status; use them to resolve references to earlier work. Never claim to remember omitted details or repeat completed work because older context is missing. Ask for the missing detail when needed.',
|
|
668
|
+
readOnly ? 'This turn is a background result notification. Only read-only lookup tools are available. Never create or continue work; follow the latest user constraints and report the result briefly.' : '',
|
|
525
669
|
formatPiCodingPrompt({
|
|
526
670
|
cwd: runtime?.cwd,
|
|
527
671
|
skillsPrompt: runtime?.skillsPrompt,
|
|
528
672
|
tools: codingTools,
|
|
529
673
|
}),
|
|
530
674
|
formatRegisteredProjectsPrompt(projects),
|
|
531
|
-
formatConnectedModelsPrompt(connectedModels),
|
|
675
|
+
formatConnectedModelsPrompt(connectedModels, { preferences: modelPreferences, catalogAvailable: modelCatalogAvailable }),
|
|
532
676
|
formatContactToolsPrompt(contactTools),
|
|
533
677
|
assistant.defaultPrompt,
|
|
534
|
-
|
|
678
|
+
codingTools.length > 0 ? WORKSPACE_RESPONSE_PROTOCOL : '',
|
|
679
|
+
].filter((value) => typeof value === 'string' && value.trim()).join('\n\n')
|
|
680
|
+
.replaceAll('{{LANGUAGE}}', resolveContactLanguage(language));
|
|
535
681
|
const model = createContactModel(providerID, modelID);
|
|
536
682
|
// streamFn receives the model; completions needs providerID/modelID.
|
|
537
683
|
model.name = `${providerID}/${modelID}`;
|
|
@@ -552,8 +698,11 @@ export async function runContactTurn({
|
|
|
552
698
|
}
|
|
553
699
|
))
|
|
554
700
|
: [];
|
|
701
|
+
// Fixed turn boundary: every completion and missed-tool retry continues bubble indices from here.
|
|
702
|
+
const turnMessageStart = prior.length;
|
|
555
703
|
|
|
556
704
|
const agent = new AgentImpl({
|
|
705
|
+
toolExecution: 'sequential',
|
|
557
706
|
initialState: {
|
|
558
707
|
systemPrompt,
|
|
559
708
|
model,
|
|
@@ -567,10 +716,17 @@ export async function runContactTurn({
|
|
|
567
716
|
onBubbleDelta,
|
|
568
717
|
globalEventHub,
|
|
569
718
|
bubbleGapMs: 280,
|
|
719
|
+
signal,
|
|
720
|
+
turnMessageStart,
|
|
570
721
|
}),
|
|
571
722
|
});
|
|
572
723
|
|
|
724
|
+
const onAbort = () => agent.abort();
|
|
725
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
726
|
+
removeAbortListener = () => signal?.removeEventListener('abort', onAbort);
|
|
727
|
+
signal?.throwIfAborted();
|
|
573
728
|
await agent.prompt(userText);
|
|
729
|
+
signal?.throwIfAborted();
|
|
574
730
|
if (agent.state.errorMessage) {
|
|
575
731
|
const error = new Error(agent.state.errorMessage);
|
|
576
732
|
error.code = 'upstream_error';
|
|
@@ -580,10 +736,10 @@ export async function runContactTurn({
|
|
|
580
736
|
const hasRequestedResult = () => agent.state.messages.some((message) => (
|
|
581
737
|
message?.role === 'toolResult' && requested.includes(message.toolName)
|
|
582
738
|
));
|
|
583
|
-
let retried = false;
|
|
584
739
|
if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
|
|
585
|
-
|
|
740
|
+
signal?.throwIfAborted();
|
|
586
741
|
await agent.prompt(MISSED_FENCE_RETRY_USER_TEXT);
|
|
742
|
+
signal?.throwIfAborted();
|
|
587
743
|
if (agent.state.errorMessage) {
|
|
588
744
|
const error = new Error(agent.state.errorMessage);
|
|
589
745
|
error.code = 'upstream_error';
|
|
@@ -592,13 +748,15 @@ export async function runContactTurn({
|
|
|
592
748
|
}
|
|
593
749
|
if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
|
|
594
750
|
// Prefer any spoken/assistant text already in the turn over the English failure fallback.
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
751
|
+
// Same turn boundary as live indices (not the retry-user slice alone).
|
|
752
|
+
const missed = extractContactTurnOutcome(agent.state.messages.slice(turnMessageStart));
|
|
753
|
+
const missedBubbles = (Array.isArray(missed.bubbles) ? missed.bubbles : [])
|
|
754
|
+
.map((item) => (typeof item === 'string' ? stripContactToolFences(item).trim() : ''))
|
|
755
|
+
.filter(Boolean);
|
|
756
|
+
if (missedBubbles.length > 0) {
|
|
599
757
|
return {
|
|
600
|
-
text:
|
|
601
|
-
bubbles: missedBubbles
|
|
758
|
+
text: missedBubbles.join('\n\n'),
|
|
759
|
+
bubbles: missedBubbles,
|
|
602
760
|
cards: [],
|
|
603
761
|
thinkingLevel: agent.state.thinkingLevel,
|
|
604
762
|
tools: [...agent.state.tools],
|
|
@@ -612,17 +770,20 @@ export async function runContactTurn({
|
|
|
612
770
|
tools: [...agent.state.tools],
|
|
613
771
|
};
|
|
614
772
|
}
|
|
615
|
-
|
|
616
|
-
const
|
|
773
|
+
// Final bubbles share the turn-boundary projection with live onBubbleDelta indices.
|
|
774
|
+
const outcome = extractContactTurnOutcome(agent.state.messages.slice(turnMessageStart));
|
|
775
|
+
const bubbles = Array.isArray(outcome.bubbles) ? outcome.bubbles : [];
|
|
776
|
+
const text = bubbles.join('\n\n') || outcome.text || '';
|
|
617
777
|
if (contactTurnHasSuccessfulReset(agent.state.messages)) {
|
|
618
778
|
const historyCleared = contactTurnClearedChatHistory(agent.state.messages);
|
|
619
779
|
const preferredConfirm = historyCleared
|
|
620
780
|
? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
|
|
621
781
|
: NEW_CONVERSATION_CONFIRM_BUBBLE;
|
|
622
|
-
|
|
782
|
+
// Reset is independent: discard mid-turn spokens; keep only the unique confirm.
|
|
783
|
+
const resetBubbles = confirmBubbleAfterContactReset(bubbles, preferredConfirm);
|
|
623
784
|
return {
|
|
624
|
-
text:
|
|
625
|
-
bubbles,
|
|
785
|
+
text: resetBubbles[0] || preferredConfirm,
|
|
786
|
+
bubbles: resetBubbles,
|
|
626
787
|
cards: [],
|
|
627
788
|
reset: true,
|
|
628
789
|
historyCleared,
|
|
@@ -636,14 +797,16 @@ export async function runContactTurn({
|
|
|
636
797
|
error.code = 'upstream_error';
|
|
637
798
|
throw error;
|
|
638
799
|
}
|
|
800
|
+
// Published stream indices are a stable prefix of the final array on normal turns.
|
|
639
801
|
return {
|
|
640
802
|
text,
|
|
641
|
-
bubbles
|
|
803
|
+
bubbles,
|
|
642
804
|
cards: outcome.cards,
|
|
643
805
|
thinkingLevel: agent.state.thinkingLevel,
|
|
644
806
|
tools: [...agent.state.tools],
|
|
645
807
|
};
|
|
646
808
|
} finally {
|
|
809
|
+
removeAbortListener?.();
|
|
647
810
|
try {
|
|
648
811
|
await runtime?.close?.();
|
|
649
812
|
} catch {
|