negotium 0.1.6 → 0.1.8
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/README.md +2 -2
- package/dist/chunk-0ay6cjdy.js +1331 -0
- package/dist/chunk-0ay6cjdy.js.map +16 -0
- package/dist/chunk-1k7t7bnx.js +97 -0
- package/dist/chunk-1k7t7bnx.js.map +10 -0
- package/dist/chunk-5ahkew9c.js +806 -0
- package/dist/chunk-5ahkew9c.js.map +17 -0
- package/dist/chunk-6cpn7ht2.js +287 -0
- package/dist/chunk-6cpn7ht2.js.map +13 -0
- package/dist/chunk-6kctn20e.js +3529 -0
- package/dist/chunk-6kctn20e.js.map +20 -0
- package/dist/chunk-7xqj7aex.js +77 -0
- package/dist/chunk-7xqj7aex.js.map +11 -0
- package/dist/chunk-9p0ww3tk.js +41 -0
- package/dist/chunk-9p0ww3tk.js.map +10 -0
- package/dist/chunk-a92a7nrk.js +252 -0
- package/dist/chunk-a92a7nrk.js.map +10 -0
- package/dist/chunk-c500xe9z.js +33 -0
- package/dist/chunk-c500xe9z.js.map +10 -0
- package/dist/chunk-cds1qxsf.js +1644 -0
- package/dist/chunk-cds1qxsf.js.map +17 -0
- package/dist/chunk-e4407g8x.js +274 -0
- package/dist/chunk-e4407g8x.js.map +10 -0
- package/dist/chunk-e5pbggsh.js +363 -0
- package/dist/chunk-e5pbggsh.js.map +11 -0
- package/dist/chunk-edb07sjp.js +567 -0
- package/dist/chunk-edb07sjp.js.map +14 -0
- package/dist/chunk-exd0v74a.js +3012 -0
- package/dist/chunk-exd0v74a.js.map +32 -0
- package/dist/chunk-fgrme9tr.js +4398 -0
- package/dist/chunk-fgrme9tr.js.map +47 -0
- package/dist/chunk-fka9eqe1.js +581 -0
- package/dist/chunk-fka9eqe1.js.map +11 -0
- package/dist/chunk-g82jygez.js +108 -0
- package/dist/chunk-g82jygez.js.map +10 -0
- package/dist/chunk-ghs2j1fr.js +312 -0
- package/dist/chunk-ghs2j1fr.js.map +10 -0
- package/dist/chunk-gmy11efb.js +125 -0
- package/dist/chunk-gmy11efb.js.map +10 -0
- package/dist/chunk-gxma4c70.js +110 -0
- package/dist/chunk-gxma4c70.js.map +10 -0
- package/dist/chunk-hkdqhm34.js +61 -0
- package/dist/chunk-hkdqhm34.js.map +10 -0
- package/dist/chunk-hpa0cpzh.js +134 -0
- package/dist/chunk-hpa0cpzh.js.map +10 -0
- package/dist/chunk-hwy3n31w.js +21 -0
- package/dist/chunk-hwy3n31w.js.map +9 -0
- package/dist/chunk-j2behpy7.js +214 -0
- package/dist/chunk-j2behpy7.js.map +10 -0
- package/dist/chunk-jc0at0tc.js +102 -0
- package/dist/chunk-jc0at0tc.js.map +10 -0
- package/dist/chunk-jkqq5ef6.js +243 -0
- package/dist/chunk-jkqq5ef6.js.map +11 -0
- package/dist/chunk-jzkw2f01.js +51 -0
- package/dist/chunk-jzkw2f01.js.map +10 -0
- package/dist/chunk-jzz91jwh.js +70 -0
- package/dist/chunk-jzz91jwh.js.map +10 -0
- package/dist/chunk-k9z47mab.js +247 -0
- package/dist/chunk-k9z47mab.js.map +12 -0
- package/dist/chunk-n17yxpa8.js +23 -0
- package/dist/chunk-n17yxpa8.js.map +10 -0
- package/dist/chunk-naa09ags.js +2143 -0
- package/dist/chunk-naa09ags.js.map +15 -0
- package/dist/chunk-ns3ke88n.js +142 -0
- package/dist/chunk-ns3ke88n.js.map +10 -0
- package/dist/chunk-p3cr2ejg.js +1626 -0
- package/dist/chunk-p3cr2ejg.js.map +15 -0
- package/dist/chunk-q5bcpf23.js +2925 -0
- package/dist/chunk-q5bcpf23.js.map +20 -0
- package/dist/chunk-rafc55bs.js +424 -0
- package/dist/chunk-rafc55bs.js.map +11 -0
- package/dist/chunk-rbrxjvbj.js +2830 -0
- package/dist/chunk-rbrxjvbj.js.map +23 -0
- package/dist/chunk-sa874j5c.js +9 -0
- package/dist/chunk-sa874j5c.js.map +10 -0
- package/dist/chunk-sgsz2fpq.js +289 -0
- package/dist/chunk-sgsz2fpq.js.map +10 -0
- package/dist/chunk-sm6fh5c5.js +569 -0
- package/dist/chunk-sm6fh5c5.js.map +14 -0
- package/dist/chunk-t9zcg8f1.js +499 -0
- package/dist/chunk-t9zcg8f1.js.map +13 -0
- package/dist/chunk-vp2db42d.js +121 -0
- package/dist/chunk-vp2db42d.js.map +10 -0
- package/dist/chunk-w13jjf4a.js +46 -0
- package/dist/chunk-w13jjf4a.js.map +10 -0
- package/dist/chunk-x1arr7ze.js +63 -0
- package/dist/chunk-x1arr7ze.js.map +10 -0
- package/dist/chunk-xfq68rch.js +583 -0
- package/dist/chunk-xfq68rch.js.map +11 -0
- package/dist/main.js +132 -20
- package/dist/main.js.map +4 -4
- package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
- package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
- package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
- package/dist/runtime/src/agents/archiver.ts +396 -0
- package/dist/runtime/src/agents/auth-check.ts +64 -0
- package/dist/runtime/src/agents/claude-provider.ts +737 -0
- package/dist/runtime/src/agents/claude-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
- package/dist/runtime/src/agents/codex-provider.ts +785 -0
- package/dist/runtime/src/agents/codex-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
- package/dist/runtime/src/agents/contracts.ts +69 -0
- package/dist/runtime/src/agents/deep-map.ts +13 -0
- package/dist/runtime/src/agents/execution-host.ts +91 -0
- package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
- package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
- package/dist/runtime/src/agents/fork.ts +65 -0
- package/dist/runtime/src/agents/hosted-agent.ts +48 -0
- package/dist/runtime/src/agents/idle-archiver.ts +128 -0
- package/dist/runtime/src/agents/index.ts +205 -0
- package/dist/runtime/src/agents/maestro-provider.ts +206 -0
- package/dist/runtime/src/agents/maestro-registry.ts +31 -0
- package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
- package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
- package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
- package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
- package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
- package/dist/runtime/src/agents/model-catalog.ts +151 -0
- package/dist/runtime/src/agents/registry.ts +24 -0
- package/dist/runtime/src/agents/rollout/claude.ts +331 -0
- package/dist/runtime/src/agents/rollout/codex.ts +569 -0
- package/dist/runtime/src/agents/rollout/shared.ts +205 -0
- package/dist/runtime/src/agents/self-config-core.ts +415 -0
- package/dist/runtime/src/agents/task-events.ts +44 -0
- package/dist/runtime/src/agents/tool-format.ts +352 -0
- package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
- package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
- package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
- package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
- package/dist/runtime/src/application/submit-user-message.ts +47 -0
- package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
- package/dist/runtime/src/application/switch-topic-model.ts +79 -0
- package/dist/runtime/src/application/topic-service.ts +139 -0
- package/dist/runtime/src/application/vault-command.ts +170 -0
- package/dist/runtime/src/bus.ts +319 -0
- package/dist/runtime/src/index.ts +333 -0
- package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
- package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
- package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
- package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
- package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
- package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
- package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
- package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
- package/dist/runtime/src/mcp/runtime-spec.ts +134 -0
- package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
- package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
- package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
- package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
- package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
- package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
- package/dist/runtime/src/mcp/system-health-server.ts +130 -0
- package/dist/runtime/src/mcp/task-server.ts +173 -0
- package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
- package/dist/runtime/src/mcp/vault-http.ts +219 -0
- package/dist/runtime/src/mcp/vault-run.ts +153 -0
- package/dist/runtime/src/mcp/vault-server.ts +104 -0
- package/dist/runtime/src/mcp/wiki-server.ts +755 -0
- package/dist/runtime/src/media/file-events.ts +33 -0
- package/dist/runtime/src/media/text-extractor.ts +312 -0
- package/dist/runtime/src/media/video.ts +92 -0
- package/dist/runtime/src/outbox/file-ops.ts +210 -0
- package/dist/runtime/src/outbox/index.ts +8 -0
- package/dist/runtime/src/outbox/utils.ts +51 -0
- package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
- package/dist/runtime/src/platform/config.ts +371 -0
- package/dist/runtime/src/platform/constants.ts +18 -0
- package/dist/runtime/src/platform/debug-users.ts +14 -0
- package/dist/runtime/src/platform/delay.ts +1 -0
- package/dist/runtime/src/platform/error.ts +9 -0
- package/dist/runtime/src/platform/file-utils.ts +16 -0
- package/dist/runtime/src/platform/jsonl.ts +202 -0
- package/dist/runtime/src/platform/lifecycle.ts +174 -0
- package/dist/runtime/src/platform/logger.ts +31 -0
- package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
- package/dist/runtime/src/platform/mcp-config.ts +865 -0
- package/dist/runtime/src/platform/modules.ts +145 -0
- package/dist/runtime/src/platform/node-plugins.ts +46 -0
- package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
- package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
- package/dist/runtime/src/prompts/builders.ts +357 -0
- package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
- package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
- package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
- package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
- package/dist/runtime/src/query/active-rooms.ts +532 -0
- package/dist/runtime/src/query/control.ts +313 -0
- package/dist/runtime/src/query/index.ts +24 -0
- package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
- package/dist/runtime/src/query/session-inbox-path.ts +42 -0
- package/dist/runtime/src/query/state.ts +94 -0
- package/dist/runtime/src/query/types.ts +83 -0
- package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
- package/dist/runtime/src/runtime/attachments.ts +151 -0
- package/dist/runtime/src/runtime/background-sessions.ts +83 -0
- package/dist/runtime/src/runtime/channel-context.ts +107 -0
- package/dist/runtime/src/runtime/errors.ts +69 -0
- package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
- package/dist/runtime/src/runtime/file-hooks.ts +81 -0
- package/dist/runtime/src/runtime/footer.ts +23 -0
- package/dist/runtime/src/runtime/inbox.ts +946 -0
- package/dist/runtime/src/runtime/tasks.ts +56 -0
- package/dist/runtime/src/runtime/topic-config.ts +29 -0
- package/dist/runtime/src/runtime/turn-runner.ts +2286 -0
- package/dist/runtime/src/runtime/usage-alert.ts +84 -0
- package/dist/runtime/src/runtime/visual-html.ts +109 -0
- package/dist/runtime/src/runtime/visual-store.ts +430 -0
- package/dist/runtime/src/runtime/visuals.ts +171 -0
- package/dist/runtime/src/security/sanitize.ts +27 -0
- package/dist/runtime/src/security/sensitive-path.ts +34 -0
- package/dist/runtime/src/storage/activity-log.ts +152 -0
- package/dist/runtime/src/storage/api-messages.ts +617 -0
- package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
- package/dist/runtime/src/storage/api-topic-config.ts +192 -0
- package/dist/runtime/src/storage/api-topics.ts +844 -0
- package/dist/runtime/src/storage/app-settings.ts +44 -0
- package/dist/runtime/src/storage/browser-profiles.ts +133 -0
- package/dist/runtime/src/storage/conversations.ts +268 -0
- package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
- package/dist/runtime/src/storage/forum/index.ts +4 -0
- package/dist/runtime/src/storage/forum/repository.ts +283 -0
- package/dist/runtime/src/storage/forum/schema.ts +300 -0
- package/dist/runtime/src/storage/forum-db.ts +23 -0
- package/dist/runtime/src/storage/runtime-events.ts +130 -0
- package/dist/runtime/src/storage/runtime-leases.ts +189 -0
- package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
- package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +300 -0
- package/dist/runtime/src/storage/self-schedules.ts +298 -0
- package/dist/runtime/src/storage/session-asks.ts +347 -0
- package/dist/runtime/src/storage/sqlite.ts +129 -0
- package/dist/runtime/src/storage/tasks.ts +179 -0
- package/dist/runtime/src/storage/token-stats.ts +144 -0
- package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
- package/dist/runtime/src/storage/topic-archive.ts +74 -0
- package/dist/runtime/src/storage/topic-settings.ts +67 -0
- package/dist/runtime/src/storage/topic-transcript.ts +98 -0
- package/dist/runtime/src/storage/vault-crypto.ts +65 -0
- package/dist/runtime/src/storage/vault.ts +225 -0
- package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
- package/dist/runtime/src/storage/wiki.ts +85 -0
- package/dist/runtime/src/topics/create.ts +123 -0
- package/dist/runtime/src/topics/derive.ts +398 -0
- package/dist/runtime/src/topics/lifecycle.ts +311 -0
- package/dist/runtime/src/topics/links.ts +7 -0
- package/dist/runtime/src/topics/personal-general.ts +65 -0
- package/dist/runtime/src/topics/session.ts +381 -0
- package/dist/runtime/src/types/api.ts +497 -0
- package/dist/runtime/src/types.ts +255 -0
- package/dist/runtime/src/version.ts +1 -0
- package/dist/runtime/tsconfig.json +13 -0
- package/package.json +16 -3
- package/dist/main.d.ts +0 -3
- package/dist/main.d.ts.map +0 -1
|
@@ -0,0 +1,2286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn runner — executes one AI turn for a topic and streams its events.
|
|
3
|
+
*
|
|
4
|
+
* Port of otium's `api/routes/ai.ts` with the REST route table removed and the
|
|
5
|
+
* WsHub replaced by the channel-agnostic RuntimeBus (`#bus`). Agent execution
|
|
6
|
+
* remains local, while an optional peerBridge routes canonical room mutations
|
|
7
|
+
* back through the placement adapter.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { mkdirSync, realpathSync, statSync } from "node:fs";
|
|
11
|
+
import { isAbsolute, resolve } from "node:path";
|
|
12
|
+
import { cleanupAgentFork, type ForkHandle } from "#agents/fork";
|
|
13
|
+
import { scheduleIdleArchiveForTopic } from "#agents/idle-archiver";
|
|
14
|
+
import { runAgent } from "#agents/index";
|
|
15
|
+
import { cancelPendingAskUserQuestions } from "#agents/mcp-tools/ask-user";
|
|
16
|
+
import {
|
|
17
|
+
settleSubagentFailure,
|
|
18
|
+
settleSubagentSuccess,
|
|
19
|
+
takeSubagentWatch,
|
|
20
|
+
} from "#agents/mcp-tools/spawn-subagent";
|
|
21
|
+
import { resolveModelForAgent } from "#agents/model-catalog";
|
|
22
|
+
import { getRegistry } from "#agents/registry";
|
|
23
|
+
import { formatToolUse, summarizeToolInput } from "#agents/tool-format";
|
|
24
|
+
import { WsHub } from "#bus";
|
|
25
|
+
import {
|
|
26
|
+
dispatchPeerRuntimeFile,
|
|
27
|
+
dispatchPeerRuntimeVisual,
|
|
28
|
+
flushPeerRuntimeEvents,
|
|
29
|
+
} from "#mcp/peer-bridge";
|
|
30
|
+
import { ensureBgBash } from "#platform/background-bash/manager";
|
|
31
|
+
import { FROM_AUTO_CONTINUE } from "#platform/constants";
|
|
32
|
+
import { logger } from "#platform/logger";
|
|
33
|
+
import { consumePlaywrightUnavailable, markPlaywrightUnavailable } from "#platform/mcp-config";
|
|
34
|
+
import {
|
|
35
|
+
ensurePlaywright,
|
|
36
|
+
getPlaywrightCapability,
|
|
37
|
+
makeInstanceKey,
|
|
38
|
+
pinPlaywrightInstance,
|
|
39
|
+
unpinPlaywrightInstance,
|
|
40
|
+
} from "#platform/playwright/manager";
|
|
41
|
+
import {
|
|
42
|
+
buildChannelSystemPrompt,
|
|
43
|
+
buildManagerSystemPrompt,
|
|
44
|
+
buildMemoryPromptSection,
|
|
45
|
+
buildTopicSystemPrompt,
|
|
46
|
+
} from "#prompts/builders";
|
|
47
|
+
import {
|
|
48
|
+
clearRoomQuery,
|
|
49
|
+
type DeferredInject,
|
|
50
|
+
DeferredInjectBatcher,
|
|
51
|
+
decideNewQuery,
|
|
52
|
+
deferInject,
|
|
53
|
+
getRoomQuery,
|
|
54
|
+
interSessionQueue,
|
|
55
|
+
isUserOrigin,
|
|
56
|
+
type RoomQueryControl,
|
|
57
|
+
setRoomQuery,
|
|
58
|
+
takeDeferredInject,
|
|
59
|
+
wsAbortReason,
|
|
60
|
+
} from "#query/active-rooms";
|
|
61
|
+
import { clearQueryState, writeQueryState } from "#query/state";
|
|
62
|
+
import { AbortReason } from "#query/types";
|
|
63
|
+
import {
|
|
64
|
+
materializePromptAttachments,
|
|
65
|
+
promptWithAttachments,
|
|
66
|
+
workspaceCwdFor,
|
|
67
|
+
} from "#runtime/attachments";
|
|
68
|
+
import { buildMentionOnlyChannelPrompt } from "#runtime/channel-context";
|
|
69
|
+
import { classifyAgentError, isSessionExpiredError, stringifyError } from "#runtime/errors";
|
|
70
|
+
import { withTurnSilenceHeartbeat } from "#runtime/event-heartbeat";
|
|
71
|
+
import { upsertTaskPanelMessage } from "#runtime/tasks";
|
|
72
|
+
import { getTopicConfig } from "#runtime/topic-config";
|
|
73
|
+
import { nextUsageAlert } from "#runtime/usage-alert";
|
|
74
|
+
import {
|
|
75
|
+
getActiveVisualForPrompt,
|
|
76
|
+
normalizeVisualTitle,
|
|
77
|
+
storeTopicMediaVisual,
|
|
78
|
+
storeTopicMermaidVisual,
|
|
79
|
+
storeTopicVisual,
|
|
80
|
+
topicVisualUrl,
|
|
81
|
+
} from "#runtime/visual-store";
|
|
82
|
+
import {
|
|
83
|
+
activeVisualHtmlForPrompt,
|
|
84
|
+
buildImageHtml,
|
|
85
|
+
buildMermaidHtml,
|
|
86
|
+
buildVideoHtml,
|
|
87
|
+
isPathInside,
|
|
88
|
+
isVisualsShowHtmlTool,
|
|
89
|
+
isVisualsShowImageTool,
|
|
90
|
+
isVisualsShowMermaidTool,
|
|
91
|
+
isVisualsShowVideoTool,
|
|
92
|
+
normalizeMermaidTheme,
|
|
93
|
+
normalizeToolUseId,
|
|
94
|
+
resolveVisualMediaInput,
|
|
95
|
+
stripMermaidFence,
|
|
96
|
+
} from "#runtime/visuals";
|
|
97
|
+
import { isSensitivePath } from "#security/sensitive-path";
|
|
98
|
+
import {
|
|
99
|
+
appendApiMessage,
|
|
100
|
+
softDeleteApiMessage,
|
|
101
|
+
updateApiMessageUsage,
|
|
102
|
+
} from "#storage/api-messages";
|
|
103
|
+
import { resolveTopicBrief } from "#storage/api-topic-brief";
|
|
104
|
+
import {
|
|
105
|
+
clearTopicSessionId,
|
|
106
|
+
getTopic,
|
|
107
|
+
getTopicMemoryOrigin,
|
|
108
|
+
getTopicSessionId,
|
|
109
|
+
setTopicSessionId,
|
|
110
|
+
} from "#storage/api-topics";
|
|
111
|
+
import { getGlobalAiName } from "#storage/app-settings";
|
|
112
|
+
import { isTopicBrowserProfileOwner } from "#storage/browser-profiles";
|
|
113
|
+
import { appendConversationEvent, readConversation } from "#storage/conversations";
|
|
114
|
+
import {
|
|
115
|
+
getRuntimeTurnLease,
|
|
116
|
+
RUNTIME_INSTANCE_ID,
|
|
117
|
+
requestRuntimeTurnAbort,
|
|
118
|
+
} from "#storage/runtime-leases";
|
|
119
|
+
import { getRuntimeTopicEpoch, isRuntimeTopicMaintenance } from "#storage/runtime-topic-state";
|
|
120
|
+
import {
|
|
121
|
+
claimNextRuntimeUserTurnRequest,
|
|
122
|
+
completeRuntimeUserTurnRequest,
|
|
123
|
+
enqueueRuntimeUserTurnRequest,
|
|
124
|
+
getRuntimeUserTurnRequest,
|
|
125
|
+
markRuntimeUserTurnRunning,
|
|
126
|
+
type RuntimeUserTurnExecution,
|
|
127
|
+
releaseRuntimeUserTurnClaim,
|
|
128
|
+
} from "#storage/runtime-turn-requests";
|
|
129
|
+
import type { PendingAskUserId } from "#storage/session-asks";
|
|
130
|
+
import { recordUsage } from "#storage/token-stats";
|
|
131
|
+
import { getSharedWikiDir } from "#storage/wiki";
|
|
132
|
+
import { wikiSummaryFilename } from "#storage/wiki-summary-names";
|
|
133
|
+
import { getTopics } from "#topics/derive";
|
|
134
|
+
import type { AgentKind, EffortLevel, PeerRuntimeBridgeContext, UnifiedEvent } from "#types";
|
|
135
|
+
import type { MessageDto, TopicDto } from "#types/api";
|
|
136
|
+
|
|
137
|
+
// 분해된 헬퍼 모듈 재노출 — 기존 @/api/routes/ai 소비자(테스트 포함) 경로 유지
|
|
138
|
+
export * from "#agents/model-catalog";
|
|
139
|
+
export * from "#runtime/attachments";
|
|
140
|
+
export * from "#runtime/channel-context";
|
|
141
|
+
export * from "#runtime/errors";
|
|
142
|
+
export * from "#runtime/tasks";
|
|
143
|
+
export * from "#runtime/visuals";
|
|
144
|
+
|
|
145
|
+
// In-flight AI turns are tracked room-keyed (topicId → control) in
|
|
146
|
+
// #query/active-rooms so a new user message on a room can supersede the
|
|
147
|
+
// running turn (Otium abort-on-new-message). See startAiTurn / abortRoom.
|
|
148
|
+
const PLAYWRIGHT_UNAVAILABLE_NOTICE_COOLDOWN_MS = 5 * 60_000;
|
|
149
|
+
const ASK_REPLY_INJECT_BATCH_MS = 500;
|
|
150
|
+
const playwrightUnavailableNoticeAt = new Map<string, number>();
|
|
151
|
+
|
|
152
|
+
// dequeueAll() is the merge primitive; this short gate makes the first reply
|
|
153
|
+
// to an idle caller wait long enough for sibling ask replies to join it.
|
|
154
|
+
const askReplyInjectBatcher = new DeferredInjectBatcher({
|
|
155
|
+
queue: interSessionQueue,
|
|
156
|
+
delayMs: ASK_REPLY_INJECT_BATCH_MS,
|
|
157
|
+
isBusy: (topicId) => Boolean(getRoomQuery(topicId)),
|
|
158
|
+
dispatch: (inject) => redispatchInject(inject),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
function appendSystemMessage(topicId: string, text: string): MessageDto {
|
|
162
|
+
const message: MessageDto = {
|
|
163
|
+
id: randomUUID(),
|
|
164
|
+
topicId,
|
|
165
|
+
authorId: "system",
|
|
166
|
+
text,
|
|
167
|
+
createdAt: new Date().toISOString(),
|
|
168
|
+
};
|
|
169
|
+
appendApiMessage(message, { notify: false });
|
|
170
|
+
WsHub.get().broadcastMessage(topicId, message);
|
|
171
|
+
return message;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function notifyPlaywrightUnavailable(topicId: string) {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const last = playwrightUnavailableNoticeAt.get(topicId) ?? 0;
|
|
177
|
+
if (now - last < PLAYWRIGHT_UNAVAILABLE_NOTICE_COOLDOWN_MS) return;
|
|
178
|
+
playwrightUnavailableNoticeAt.set(topicId, now);
|
|
179
|
+
appendSystemMessage(
|
|
180
|
+
topicId,
|
|
181
|
+
"Playwright browser tools are unavailable this turn. Browser automation was removed from the tool catalog; retry shortly if browser interaction is required.",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function appendAskReplyMessage(
|
|
186
|
+
topicId: string,
|
|
187
|
+
text: string,
|
|
188
|
+
agentType?: AgentKind | null,
|
|
189
|
+
): MessageDto {
|
|
190
|
+
const message: MessageDto = {
|
|
191
|
+
id: randomUUID(),
|
|
192
|
+
topicId,
|
|
193
|
+
authorId: "ai",
|
|
194
|
+
text,
|
|
195
|
+
...(agentType ? { agentType } : {}),
|
|
196
|
+
createdAt: new Date().toISOString(),
|
|
197
|
+
};
|
|
198
|
+
appendApiMessage(message, { notify: false });
|
|
199
|
+
WsHub.get().broadcastMessage(topicId, message);
|
|
200
|
+
return message;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type StreamAgentOutcome =
|
|
204
|
+
| { kind: "completed" }
|
|
205
|
+
| { kind: "aborted" }
|
|
206
|
+
| { kind: "session-expired"; error: string }
|
|
207
|
+
| { kind: "provider-error"; error: string };
|
|
208
|
+
|
|
209
|
+
function sessionEventMatchesCurrentExecution(
|
|
210
|
+
topicId: string,
|
|
211
|
+
queryId: string,
|
|
212
|
+
agent: AgentKind,
|
|
213
|
+
model: string,
|
|
214
|
+
): boolean {
|
|
215
|
+
if (getRoomQuery(topicId)?.queryId !== queryId) return false;
|
|
216
|
+
const topic = getTopic(topicId);
|
|
217
|
+
if (!topic || topic.agent !== agent) return false;
|
|
218
|
+
const registry = getRegistry(agent);
|
|
219
|
+
const configuredModel = resolveModelForAgent(
|
|
220
|
+
agent,
|
|
221
|
+
getTopicConfig(topicId)?.model ?? topic.defaultModel,
|
|
222
|
+
registry,
|
|
223
|
+
);
|
|
224
|
+
return configuredModel === model;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function streamAgentEvents(
|
|
228
|
+
topicId: string,
|
|
229
|
+
topicTitle: string,
|
|
230
|
+
queryId: string,
|
|
231
|
+
events: AsyncGenerator<UnifiedEvent>,
|
|
232
|
+
control: RoomQueryControl,
|
|
233
|
+
agentType: AgentKind,
|
|
234
|
+
model: string,
|
|
235
|
+
_effort: EffortLevel | undefined,
|
|
236
|
+
userId: string,
|
|
237
|
+
retryableSessionExpired = true,
|
|
238
|
+
onSessionId?: (sessionId: string) => void,
|
|
239
|
+
execution?: { silent?: boolean; peerBridge?: PeerRuntimeBridgeContext },
|
|
240
|
+
): Promise<StreamAgentOutcome> {
|
|
241
|
+
const abortController = control.abortController;
|
|
242
|
+
const hub = WsHub.get();
|
|
243
|
+
const silent = execution?.silent ?? control.injectParams?.silent ?? false;
|
|
244
|
+
const peerBridge = execution?.peerBridge ?? control.injectParams?.peerBridge;
|
|
245
|
+
let errorOccurred = false;
|
|
246
|
+
let terminalEmitted = false;
|
|
247
|
+
let pendingSawDelta = false;
|
|
248
|
+
let accumulatedText = "";
|
|
249
|
+
let pendingText = "";
|
|
250
|
+
let lastVisibleMessageId: string | null = null;
|
|
251
|
+
const visibleMessageIds: string[] = [];
|
|
252
|
+
let lastTaskPanelText: string | null = null;
|
|
253
|
+
let syntheticToolCounter = 0;
|
|
254
|
+
const providerToolIds = new Map<string, string>();
|
|
255
|
+
const anonymousToolIds: string[] = [];
|
|
256
|
+
const handledVisualToolResultIds = new Set<string>();
|
|
257
|
+
const nextSyntheticToolUseId = () => `tool-${queryId}-${++syntheticToolCounter}`;
|
|
258
|
+
const bindToolUseId = (providerToolUseId?: string, fallback?: string): string => {
|
|
259
|
+
const providerId = normalizeToolUseId(providerToolUseId);
|
|
260
|
+
if (providerId) {
|
|
261
|
+
const existing = providerToolIds.get(providerId);
|
|
262
|
+
if (existing) return existing;
|
|
263
|
+
const clientId = fallback ?? providerId;
|
|
264
|
+
providerToolIds.set(providerId, clientId);
|
|
265
|
+
return clientId;
|
|
266
|
+
}
|
|
267
|
+
const clientId = fallback ?? nextSyntheticToolUseId();
|
|
268
|
+
anonymousToolIds.push(clientId);
|
|
269
|
+
return clientId;
|
|
270
|
+
};
|
|
271
|
+
const resolveToolResultId = (providerToolUseId: string): string => {
|
|
272
|
+
const providerId = normalizeToolUseId(providerToolUseId);
|
|
273
|
+
if (providerId) {
|
|
274
|
+
const existing = providerToolIds.get(providerId);
|
|
275
|
+
if (existing) return existing;
|
|
276
|
+
const anonymousClientId = anonymousToolIds.shift();
|
|
277
|
+
if (anonymousClientId) {
|
|
278
|
+
providerToolIds.set(providerId, anonymousClientId);
|
|
279
|
+
return anonymousClientId;
|
|
280
|
+
}
|
|
281
|
+
return providerId;
|
|
282
|
+
}
|
|
283
|
+
return anonymousToolIds.shift() ?? nextSyntheticToolUseId();
|
|
284
|
+
};
|
|
285
|
+
const markVisualToolResultHandled = (providerToolUseId?: string) => {
|
|
286
|
+
const providerId = normalizeToolUseId(providerToolUseId);
|
|
287
|
+
if (providerId) handledVisualToolResultIds.add(providerId);
|
|
288
|
+
};
|
|
289
|
+
const isVisualToolResultHandled = (providerToolUseId: string): boolean => {
|
|
290
|
+
const providerId = normalizeToolUseId(providerToolUseId);
|
|
291
|
+
return providerId ? handledVisualToolResultIds.has(providerId) : false;
|
|
292
|
+
};
|
|
293
|
+
const emitPendingAssistantMessage = (usage?: MessageDto["usage"]): MessageDto | null => {
|
|
294
|
+
const text = pendingText.trimEnd();
|
|
295
|
+
pendingText = "";
|
|
296
|
+
pendingSawDelta = false;
|
|
297
|
+
if (silent || !text.trim()) return null;
|
|
298
|
+
const message: MessageDto = {
|
|
299
|
+
id: randomUUID(),
|
|
300
|
+
topicId,
|
|
301
|
+
authorId: "ai",
|
|
302
|
+
text,
|
|
303
|
+
queryId,
|
|
304
|
+
agentType,
|
|
305
|
+
model,
|
|
306
|
+
usage,
|
|
307
|
+
createdAt: new Date().toISOString(),
|
|
308
|
+
};
|
|
309
|
+
appendApiMessage(message);
|
|
310
|
+
hub.broadcastMessage(topicId, message);
|
|
311
|
+
lastVisibleMessageId = message.id;
|
|
312
|
+
visibleMessageIds.push(message.id);
|
|
313
|
+
return message;
|
|
314
|
+
};
|
|
315
|
+
const discardVisibleAssistantMessages = (): void => {
|
|
316
|
+
for (const messageId of visibleMessageIds.splice(0)) {
|
|
317
|
+
const deleted = softDeleteApiMessage(topicId, messageId);
|
|
318
|
+
if (deleted) {
|
|
319
|
+
hub.broadcastMessageUpdated(topicId, messageId, { deleted: true, text: "" });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
lastVisibleMessageId = null;
|
|
323
|
+
};
|
|
324
|
+
const broadcastStoredVisual = (
|
|
325
|
+
event: Extract<UnifiedEvent, { type: "tool_use" }>,
|
|
326
|
+
vizId: number,
|
|
327
|
+
title: string | undefined,
|
|
328
|
+
kind: "html" | "mermaid",
|
|
329
|
+
): void => {
|
|
330
|
+
const toolUseId = bindToolUseId(event.toolUseId, `visual-${vizId}`);
|
|
331
|
+
hub.broadcastToolCall(
|
|
332
|
+
topicId,
|
|
333
|
+
queryId,
|
|
334
|
+
event.name,
|
|
335
|
+
summarizeToolInput(event.name, event.input),
|
|
336
|
+
formatToolUse(event.name, event.input),
|
|
337
|
+
toolUseId,
|
|
338
|
+
);
|
|
339
|
+
const url = topicVisualUrl(topicId, vizId);
|
|
340
|
+
hub.broadcastVisual(topicId, queryId, url, vizId, title ?? null, kind);
|
|
341
|
+
hub.broadcastToolOutput(topicId, queryId, toolUseId, `Displayed: ${url}`);
|
|
342
|
+
markVisualToolResultHandled(event.toolUseId);
|
|
343
|
+
};
|
|
344
|
+
const broadcastBridgedVisual = (
|
|
345
|
+
event: Extract<UnifiedEvent, { type: "tool_use" }>,
|
|
346
|
+
visual: { id: number; url: string },
|
|
347
|
+
): void => {
|
|
348
|
+
const toolUseId = bindToolUseId(event.toolUseId, `visual-${visual.id}`);
|
|
349
|
+
// The hub bridge already stored and broadcast the visual. Only complete
|
|
350
|
+
// the worker-side tool row; forwarding another visual would duplicate it.
|
|
351
|
+
hub.broadcastToolOutput(topicId, queryId, toolUseId, `Displayed: ${visual.url}`);
|
|
352
|
+
markVisualToolResultHandled(event.toolUseId);
|
|
353
|
+
};
|
|
354
|
+
const failBridgedVisual = (
|
|
355
|
+
event: Extract<UnifiedEvent, { type: "tool_use" }>,
|
|
356
|
+
kind: "html" | "mermaid" | "image" | "video",
|
|
357
|
+
reason: string,
|
|
358
|
+
): void => {
|
|
359
|
+
logger.warn(
|
|
360
|
+
{ topicId, queryId, toolName: event.name, error: reason },
|
|
361
|
+
"peer visual bridge failed",
|
|
362
|
+
);
|
|
363
|
+
hub.broadcastToolOutput(
|
|
364
|
+
topicId,
|
|
365
|
+
queryId,
|
|
366
|
+
bindToolUseId(event.toolUseId),
|
|
367
|
+
`Failed to display ${kind}: ${reason}`,
|
|
368
|
+
);
|
|
369
|
+
markVisualToolResultHandled(event.toolUseId);
|
|
370
|
+
};
|
|
371
|
+
let outcome: StreamAgentOutcome = { kind: "completed" };
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
for await (const event of events) {
|
|
375
|
+
if (abortController.signal.aborted) break;
|
|
376
|
+
|
|
377
|
+
switch (event.type) {
|
|
378
|
+
case "text_delta": {
|
|
379
|
+
pendingSawDelta = true;
|
|
380
|
+
const incoming = event.content;
|
|
381
|
+
accumulatedText += incoming;
|
|
382
|
+
pendingText += incoming;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
case "text":
|
|
386
|
+
if (!pendingSawDelta) {
|
|
387
|
+
const incoming = event.content;
|
|
388
|
+
accumulatedText += incoming;
|
|
389
|
+
pendingText += incoming;
|
|
390
|
+
}
|
|
391
|
+
break;
|
|
392
|
+
case "tool_use":
|
|
393
|
+
// Provider text is streamed before the tool event that follows it.
|
|
394
|
+
// Persist that completed segment first so every host receives the
|
|
395
|
+
// original assistant → tool → assistant ordering instead of a block
|
|
396
|
+
// of tools followed by one consolidated answer at turn completion.
|
|
397
|
+
emitPendingAssistantMessage();
|
|
398
|
+
// Match both the bare name (Codex reports MCP server/tool separately)
|
|
399
|
+
// and each provider's public MCP-name form.
|
|
400
|
+
if (isVisualsShowHtmlTool(event.name)) {
|
|
401
|
+
const input = event.input as { html?: unknown; title?: unknown };
|
|
402
|
+
if (typeof input.html !== "string" || input.html.trim().length === 0) {
|
|
403
|
+
logger.warn(
|
|
404
|
+
{ topicId, queryId, toolName: event.name },
|
|
405
|
+
"show_html tool_use missing html input; ignoring visual render",
|
|
406
|
+
);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
if (!silent) {
|
|
410
|
+
const title = normalizeVisualTitle(input.title);
|
|
411
|
+
if (peerBridge) {
|
|
412
|
+
hub.broadcastToolCall(
|
|
413
|
+
topicId,
|
|
414
|
+
queryId,
|
|
415
|
+
event.name,
|
|
416
|
+
summarizeToolInput(event.name, event.input),
|
|
417
|
+
formatToolUse(event.name, event.input),
|
|
418
|
+
bindToolUseId(event.toolUseId),
|
|
419
|
+
);
|
|
420
|
+
if (!(await flushPeerRuntimeEvents(topicId))) {
|
|
421
|
+
failBridgedVisual(event, "html", "ordered event delivery is blocked");
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
const bridged = await dispatchPeerRuntimeVisual({
|
|
425
|
+
bridge: peerBridge,
|
|
426
|
+
userId,
|
|
427
|
+
agent: agentType,
|
|
428
|
+
model,
|
|
429
|
+
kind: "html",
|
|
430
|
+
title,
|
|
431
|
+
html: input.html,
|
|
432
|
+
});
|
|
433
|
+
if (!bridged?.ok) {
|
|
434
|
+
failBridgedVisual(event, "html", bridged?.error ?? "visual bridge unavailable");
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
broadcastBridgedVisual(event, bridged);
|
|
438
|
+
} else {
|
|
439
|
+
const vizId = storeTopicVisual(topicId, input.html, title, userId);
|
|
440
|
+
broadcastStoredVisual(event, vizId, title, "html");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
} else if (isVisualsShowMermaidTool(event.name)) {
|
|
444
|
+
const input = event.input as { code?: unknown; title?: unknown; theme?: unknown };
|
|
445
|
+
const code = typeof input.code === "string" ? stripMermaidFence(input.code) : "";
|
|
446
|
+
if (!code) {
|
|
447
|
+
logger.warn(
|
|
448
|
+
{ topicId, queryId, toolName: event.name },
|
|
449
|
+
"show_mermaid tool_use missing code input; ignoring visual render",
|
|
450
|
+
);
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
if (!silent) {
|
|
454
|
+
const title = normalizeVisualTitle(input.title);
|
|
455
|
+
const theme = normalizeMermaidTheme(input.theme);
|
|
456
|
+
if (peerBridge) {
|
|
457
|
+
hub.broadcastToolCall(
|
|
458
|
+
topicId,
|
|
459
|
+
queryId,
|
|
460
|
+
event.name,
|
|
461
|
+
summarizeToolInput(event.name, event.input),
|
|
462
|
+
formatToolUse(event.name, event.input),
|
|
463
|
+
bindToolUseId(event.toolUseId),
|
|
464
|
+
);
|
|
465
|
+
if (!(await flushPeerRuntimeEvents(topicId))) {
|
|
466
|
+
failBridgedVisual(event, "mermaid", "ordered event delivery is blocked");
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
const bridged = await dispatchPeerRuntimeVisual({
|
|
470
|
+
bridge: peerBridge,
|
|
471
|
+
userId,
|
|
472
|
+
agent: agentType,
|
|
473
|
+
model,
|
|
474
|
+
kind: "mermaid",
|
|
475
|
+
title,
|
|
476
|
+
code,
|
|
477
|
+
theme,
|
|
478
|
+
});
|
|
479
|
+
if (!bridged?.ok) {
|
|
480
|
+
failBridgedVisual(
|
|
481
|
+
event,
|
|
482
|
+
"mermaid",
|
|
483
|
+
bridged?.error ?? "visual bridge unavailable",
|
|
484
|
+
);
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
broadcastBridgedVisual(event, bridged);
|
|
488
|
+
} else {
|
|
489
|
+
const html = buildMermaidHtml(code, theme);
|
|
490
|
+
const vizId = storeTopicMermaidVisual(topicId, code, html, title, userId);
|
|
491
|
+
broadcastStoredVisual(event, vizId, title, "mermaid");
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
} else if (isVisualsShowImageTool(event.name) || isVisualsShowVideoTool(event.name)) {
|
|
495
|
+
const input = event.input as {
|
|
496
|
+
file_path?: unknown;
|
|
497
|
+
file_id?: unknown;
|
|
498
|
+
title?: unknown;
|
|
499
|
+
alt?: unknown;
|
|
500
|
+
};
|
|
501
|
+
const kind = isVisualsShowImageTool(event.name) ? "image" : "video";
|
|
502
|
+
const toolUseId = bindToolUseId(event.toolUseId);
|
|
503
|
+
const label = formatToolUse(event.name, event.input);
|
|
504
|
+
const failMediaVisual = (reason: string) => {
|
|
505
|
+
logger.warn(
|
|
506
|
+
{ topicId, queryId, toolName: event.name, error: reason },
|
|
507
|
+
"media visual tool_use could not render visual",
|
|
508
|
+
);
|
|
509
|
+
if (!silent) {
|
|
510
|
+
hub.broadcastToolOutput(
|
|
511
|
+
topicId,
|
|
512
|
+
queryId,
|
|
513
|
+
toolUseId,
|
|
514
|
+
`Failed to display ${kind}: ${reason}`,
|
|
515
|
+
);
|
|
516
|
+
markVisualToolResultHandled(event.toolUseId);
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
if (!silent) {
|
|
520
|
+
hub.broadcastToolCall(
|
|
521
|
+
topicId,
|
|
522
|
+
queryId,
|
|
523
|
+
event.name,
|
|
524
|
+
summarizeToolInput(event.name, event.input),
|
|
525
|
+
label,
|
|
526
|
+
toolUseId,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
if (silent) break;
|
|
530
|
+
const media = resolveVisualMediaInput(topicId, input);
|
|
531
|
+
if ("error" in media) {
|
|
532
|
+
failMediaVisual(media.error);
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
if (kind === "image" && !media.mimeType.startsWith("image/")) {
|
|
536
|
+
failMediaVisual(`expected image media, got ${media.mimeType}`);
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
if (kind === "video" && !media.mimeType.startsWith("video/")) {
|
|
540
|
+
failMediaVisual(`expected video media, got ${media.mimeType}`);
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
const title = normalizeVisualTitle(input.title);
|
|
545
|
+
if (peerBridge) {
|
|
546
|
+
if (!(await flushPeerRuntimeEvents(topicId))) {
|
|
547
|
+
failMediaVisual("ordered event delivery is blocked");
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
const bridged = await dispatchPeerRuntimeVisual({
|
|
551
|
+
bridge: peerBridge,
|
|
552
|
+
userId,
|
|
553
|
+
agent: agentType,
|
|
554
|
+
model,
|
|
555
|
+
kind,
|
|
556
|
+
title,
|
|
557
|
+
fileId: media.fileId,
|
|
558
|
+
mimeType: media.mimeType,
|
|
559
|
+
source: media.source,
|
|
560
|
+
});
|
|
561
|
+
if (!bridged?.ok) {
|
|
562
|
+
failMediaVisual(bridged?.error ?? "visual bridge unavailable");
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
broadcastBridgedVisual(event, bridged);
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
const html =
|
|
569
|
+
kind === "image"
|
|
570
|
+
? buildImageHtml(
|
|
571
|
+
typeof input.alt === "string" ? input.alt : (title ?? "Visual image"),
|
|
572
|
+
)
|
|
573
|
+
: buildVideoHtml(media.mimeType);
|
|
574
|
+
const vizId = storeTopicMediaVisual({
|
|
575
|
+
topicId,
|
|
576
|
+
kind,
|
|
577
|
+
html,
|
|
578
|
+
title,
|
|
579
|
+
fileId: media.fileId,
|
|
580
|
+
mimeType: media.mimeType,
|
|
581
|
+
source: media.source,
|
|
582
|
+
activeUserId: userId,
|
|
583
|
+
});
|
|
584
|
+
const url = topicVisualUrl(topicId, vizId);
|
|
585
|
+
hub.broadcastVisual(topicId, queryId, url, vizId, title ?? null, kind);
|
|
586
|
+
hub.broadcastToolOutput(topicId, queryId, toolUseId, `Displayed: ${url}`);
|
|
587
|
+
markVisualToolResultHandled(event.toolUseId);
|
|
588
|
+
} catch (err) {
|
|
589
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
590
|
+
failMediaVisual(reason);
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
// Label is computed server-side (single source of truth) so the
|
|
594
|
+
// client renders the same "Bash(npm test)" convention everywhere.
|
|
595
|
+
const label = formatToolUse(event.name, event.input);
|
|
596
|
+
const toolUseId = bindToolUseId(event.toolUseId);
|
|
597
|
+
if (!silent) {
|
|
598
|
+
hub.broadcastToolCall(
|
|
599
|
+
topicId,
|
|
600
|
+
queryId,
|
|
601
|
+
event.name,
|
|
602
|
+
summarizeToolInput(event.name, event.input),
|
|
603
|
+
label,
|
|
604
|
+
toolUseId,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
break;
|
|
609
|
+
case "tool_result":
|
|
610
|
+
if (isVisualToolResultHandled(event.toolUseId)) break;
|
|
611
|
+
if (!silent) {
|
|
612
|
+
hub.broadcastToolOutput(
|
|
613
|
+
topicId,
|
|
614
|
+
queryId,
|
|
615
|
+
resolveToolResultId(event.toolUseId),
|
|
616
|
+
event.content,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
break;
|
|
620
|
+
case "file":
|
|
621
|
+
if (!silent && peerBridge) {
|
|
622
|
+
const cwd = workspaceCwdFor(topicId);
|
|
623
|
+
const path = isAbsolute(event.path) ? event.path : resolve(cwd, event.path);
|
|
624
|
+
if (!isPathInside(cwd, path)) {
|
|
625
|
+
logger.warn({ topicId, path }, "peer output file is outside the topic workspace");
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
let safePath: string;
|
|
629
|
+
try {
|
|
630
|
+
safePath = realpathSync(path);
|
|
631
|
+
if (!statSync(safePath).isFile()) {
|
|
632
|
+
logger.warn({ topicId, path }, "peer output path is not a regular file");
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
} catch (error) {
|
|
636
|
+
logger.warn({ topicId, path, error }, "peer output file is unavailable");
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
if (isSensitivePath(safePath)) {
|
|
640
|
+
logger.warn({ topicId, path: safePath }, "peer output file is sensitive");
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
const sent = await dispatchPeerRuntimeFile({
|
|
644
|
+
bridge: peerBridge,
|
|
645
|
+
userId,
|
|
646
|
+
agent: agentType,
|
|
647
|
+
model,
|
|
648
|
+
path: safePath,
|
|
649
|
+
source: event.source,
|
|
650
|
+
});
|
|
651
|
+
if (!sent?.ok)
|
|
652
|
+
logger.warn({ error: sent?.error, path }, "peer output file bridge failed");
|
|
653
|
+
} else if (!silent) {
|
|
654
|
+
hub.broadcastFileReady(topicId, queryId, event.path, event.source);
|
|
655
|
+
}
|
|
656
|
+
break;
|
|
657
|
+
case "result":
|
|
658
|
+
if (event.usage) recordUsage(userId, topicTitle, event.usage);
|
|
659
|
+
{
|
|
660
|
+
const usage: MessageDto["usage"] = event.usage
|
|
661
|
+
? {
|
|
662
|
+
input: event.usage.inputTokens,
|
|
663
|
+
output: event.usage.outputTokens,
|
|
664
|
+
cachedInput: event.usage.cacheReadInputTokens,
|
|
665
|
+
context: event.usage.contextTokens,
|
|
666
|
+
contextWindow: event.usage.contextWindow,
|
|
667
|
+
}
|
|
668
|
+
: undefined;
|
|
669
|
+
const finalMessage = emitPendingAssistantMessage(usage);
|
|
670
|
+
// A tool-only ending has no trailing text segment to carry usage.
|
|
671
|
+
// Attach it to the latest visible segment without changing its
|
|
672
|
+
// timeline position; ai_done still carries the same usage to
|
|
673
|
+
// remote hosts that do not accept generic message patches.
|
|
674
|
+
if (!finalMessage && usage && lastVisibleMessageId) {
|
|
675
|
+
const updated = updateApiMessageUsage(topicId, lastVisibleMessageId, usage);
|
|
676
|
+
if (updated) {
|
|
677
|
+
hub.broadcastMessageUpdated(topicId, lastVisibleMessageId, { usage });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (!silent) {
|
|
682
|
+
scheduleIdleArchiveForTopic(topicId, userId);
|
|
683
|
+
hub.broadcastDone(
|
|
684
|
+
topicId,
|
|
685
|
+
queryId,
|
|
686
|
+
event.usage
|
|
687
|
+
? {
|
|
688
|
+
input: event.usage.inputTokens,
|
|
689
|
+
output: event.usage.outputTokens,
|
|
690
|
+
cachedInput: event.usage.cacheReadInputTokens,
|
|
691
|
+
context: event.usage.contextTokens,
|
|
692
|
+
contextWindow: event.usage.contextWindow,
|
|
693
|
+
}
|
|
694
|
+
: undefined,
|
|
695
|
+
{ agent: agentType, model },
|
|
696
|
+
);
|
|
697
|
+
// Session-bloat notice: warn once when the provider reports at
|
|
698
|
+
// least 80% context occupancy. Skip subagent rooms (transient).
|
|
699
|
+
const finishedTopic = getTopic(topicId);
|
|
700
|
+
if (event.usage && finishedTopic?.kind !== "manager" && !finishedTopic?.isSubagent) {
|
|
701
|
+
const usageAlert = nextUsageAlert(userId, topicId, topicTitle, event.usage);
|
|
702
|
+
if (usageAlert) appendSystemMessage(topicId, usageAlert);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
terminalEmitted = true;
|
|
706
|
+
|
|
707
|
+
// ── Ask callback: route reply to the caller topic ──────────
|
|
708
|
+
{
|
|
709
|
+
const { resolveAskCallback } = await import("#runtime/ask-callbacks");
|
|
710
|
+
const pending = resolveAskCallback(queryId);
|
|
711
|
+
if (pending) {
|
|
712
|
+
const sourceLabel = topicTitle;
|
|
713
|
+
const replyText = pending.timedOut
|
|
714
|
+
? `⚠️ [Timeout - reply from ${sourceLabel} took too long and was dropped]`
|
|
715
|
+
: accumulatedText.trim() || "(처리됨, 별도 응답 없음)";
|
|
716
|
+
await deliverAskCallbackToCaller(pending, sourceLabel, replyText, "reply");
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ── Subagent settlement: card → completed, result → parent room ──
|
|
721
|
+
{
|
|
722
|
+
const watch = takeSubagentWatch(queryId);
|
|
723
|
+
if (watch) void settleSubagentSuccess(watch, accumulatedText.trim());
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
break;
|
|
727
|
+
case "error":
|
|
728
|
+
logger.warn(
|
|
729
|
+
{ topicId, queryId, agentType, model, silent, error: event.content },
|
|
730
|
+
"ai: provider returned error",
|
|
731
|
+
);
|
|
732
|
+
terminalEmitted = true;
|
|
733
|
+
errorOccurred = true;
|
|
734
|
+
if (retryableSessionExpired && isSessionExpiredError(event.content)) {
|
|
735
|
+
outcome = { kind: "session-expired", error: event.content };
|
|
736
|
+
return outcome;
|
|
737
|
+
}
|
|
738
|
+
outcome = { kind: "provider-error", error: event.content };
|
|
739
|
+
if (silent) deliverAskError(queryId, topicTitle, event.content);
|
|
740
|
+
return outcome;
|
|
741
|
+
case "status":
|
|
742
|
+
if (!silent) hub.broadcastToolStatus(topicId, queryId, "status", event.content);
|
|
743
|
+
break;
|
|
744
|
+
case "user_message":
|
|
745
|
+
break;
|
|
746
|
+
case "tool_progress":
|
|
747
|
+
if (!silent) {
|
|
748
|
+
const elapsed = Math.max(0, Math.round(event.elapsed));
|
|
749
|
+
// "thinking" is the provider's extended-thinking heartbeat, not a
|
|
750
|
+
// tool — label it as a live thinking state instead of "running".
|
|
751
|
+
const label =
|
|
752
|
+
event.toolName === "thinking"
|
|
753
|
+
? `Thinking… ${elapsed}s`
|
|
754
|
+
: event.toolName === "working"
|
|
755
|
+
? `Working… ${elapsed}s`
|
|
756
|
+
: `${event.toolName} running ${elapsed}s`;
|
|
757
|
+
hub.broadcastToolStatus(topicId, queryId, "progress", label, {
|
|
758
|
+
toolName: event.toolName,
|
|
759
|
+
elapsed,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
break;
|
|
763
|
+
case "tool_use_summary":
|
|
764
|
+
if (!silent) hub.broadcastToolStatus(topicId, queryId, "summary", event.summary);
|
|
765
|
+
break;
|
|
766
|
+
case "reasoning":
|
|
767
|
+
if (!silent) hub.broadcastReasoning(topicId, queryId, event.content);
|
|
768
|
+
break;
|
|
769
|
+
case "tasks":
|
|
770
|
+
if (!silent) {
|
|
771
|
+
lastTaskPanelText = upsertTaskPanelMessage(
|
|
772
|
+
topicId,
|
|
773
|
+
queryId,
|
|
774
|
+
event.tasks,
|
|
775
|
+
lastTaskPanelText,
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
break;
|
|
779
|
+
case "session":
|
|
780
|
+
// Persist visible topic turns only. Silent ask_session forks use
|
|
781
|
+
// temporary session ids and must not clobber the topic's durable
|
|
782
|
+
// session; visible tell/reply injects do belong to the main topic
|
|
783
|
+
// session, same as user messages.
|
|
784
|
+
if (!silent && onSessionId && getRoomQuery(topicId)?.queryId === queryId) {
|
|
785
|
+
try {
|
|
786
|
+
// A user turn may preempt and requeue this injected turn. Carry
|
|
787
|
+
// the newest provider session into that deferred copy.
|
|
788
|
+
if (control.injectParams) control.injectParams.sessionId = event.sessionId;
|
|
789
|
+
onSessionId(event.sessionId);
|
|
790
|
+
} catch (err) {
|
|
791
|
+
logger.warn(
|
|
792
|
+
{ err, topicId, queryId, agentType },
|
|
793
|
+
"ai: isolated session owner rejected provider session id",
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
} else if (
|
|
797
|
+
!silent &&
|
|
798
|
+
sessionEventMatchesCurrentExecution(topicId, queryId, agentType, model)
|
|
799
|
+
) {
|
|
800
|
+
setTopicSessionId(topicId, event.sessionId, {
|
|
801
|
+
reason: "provider-session-event",
|
|
802
|
+
queryId,
|
|
803
|
+
agent: agentType,
|
|
804
|
+
});
|
|
805
|
+
} else if (!silent) {
|
|
806
|
+
logger.info(
|
|
807
|
+
{ topicId, queryId, agentType, model, sessionId: event.sessionId.slice(0, 8) },
|
|
808
|
+
"ai: ignored stale provider session event after execution config changed",
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (!errorOccurred && abortController.signal.aborted) {
|
|
816
|
+
// Keep text already spoken before either an explicit stop or a new user
|
|
817
|
+
// message supersedes the turn. Adapters clean up transient tool status
|
|
818
|
+
// separately when they receive ai_aborted.
|
|
819
|
+
emitPendingAssistantMessage();
|
|
820
|
+
if (!silent) hub.broadcastAborted(topicId, queryId, wsAbortReason(control.abortReason));
|
|
821
|
+
terminalEmitted = true;
|
|
822
|
+
outcome = { kind: "aborted" };
|
|
823
|
+
// Internal abort = this turn is being replaced and (if it was a session
|
|
824
|
+
// inject) re-queued — NOT a failure. Suppress the asker notification so
|
|
825
|
+
// the caller doesn't get a spurious "Aborted" followed by the real reply
|
|
826
|
+
// from the re-queued turn. Only External/stop notifies the asker.
|
|
827
|
+
if (control.abortReason !== AbortReason.Internal) {
|
|
828
|
+
deliverAskError(queryId, topicTitle, "Aborted");
|
|
829
|
+
void settleSubagentFailure(queryId, "중지됨 (사용자가 실행을 멈췄습니다)");
|
|
830
|
+
} else {
|
|
831
|
+
// The replay receives a new queryId and registers a fresh callback.
|
|
832
|
+
// Drop the superseded mapping now so it cannot leak until TTL expiry.
|
|
833
|
+
const { resolveAskCallback } = await import("#runtime/ask-callbacks");
|
|
834
|
+
resolveAskCallback(queryId);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
} catch (err) {
|
|
838
|
+
const msg = stringifyError(err) || "Unknown agent error";
|
|
839
|
+
logger.warn({ err, topicId, queryId, agentType, model, silent }, "ai: stream failed");
|
|
840
|
+
terminalEmitted = true;
|
|
841
|
+
if (retryableSessionExpired && isSessionExpiredError(msg)) {
|
|
842
|
+
outcome = { kind: "session-expired", error: msg };
|
|
843
|
+
} else {
|
|
844
|
+
outcome = { kind: "provider-error", error: msg };
|
|
845
|
+
if (silent) deliverAskError(queryId, topicTitle, msg);
|
|
846
|
+
}
|
|
847
|
+
} finally {
|
|
848
|
+
const discardIncompleteSegments =
|
|
849
|
+
outcome.kind === "session-expired" ||
|
|
850
|
+
outcome.kind === "provider-error" ||
|
|
851
|
+
(!terminalEmitted && !abortController.signal.aborted);
|
|
852
|
+
if (discardIncompleteSegments) discardVisibleAssistantMessages();
|
|
853
|
+
if (!terminalEmitted && !silent) {
|
|
854
|
+
hub.broadcastDone(topicId, queryId, undefined, { agent: agentType, model });
|
|
855
|
+
}
|
|
856
|
+
// Release the room slot — but only if a newer turn hasn't already taken it
|
|
857
|
+
// (abort-and-replace sets a fresh control synchronously before this dying
|
|
858
|
+
// turn's generator unwinds here). Query-state cleanup follows the same
|
|
859
|
+
// guard so a superseded turn cannot delete its replacement's state file.
|
|
860
|
+
const stillCurrent = getRoomQuery(topicId)?.queryId === queryId;
|
|
861
|
+
clearRoomQuery(topicId, queryId);
|
|
862
|
+
cancelPendingAskUserQuestions(topicId, queryId);
|
|
863
|
+
if (stillCurrent) clearQueryState(userId, topicTitle);
|
|
864
|
+
// Stop typing only if this turn still owns the room. A superseded turn may
|
|
865
|
+
// finish cleanup after its replacement already broadcast a fresh start.
|
|
866
|
+
if (!silent && stillCurrent) hub.broadcastTyping(topicId, "");
|
|
867
|
+
const forkHandle = control.injectParams?.forkHandle;
|
|
868
|
+
const forkRequestId = control.injectParams?.requestId;
|
|
869
|
+
if (
|
|
870
|
+
forkHandle &&
|
|
871
|
+
outcome.kind !== "session-expired" &&
|
|
872
|
+
(!forkRequestId || !interSessionQueue.hasRequest(topicId, forkRequestId))
|
|
873
|
+
) {
|
|
874
|
+
cleanupAgentFork(forkHandle);
|
|
875
|
+
}
|
|
876
|
+
// Drain one deferred session-inject if the room is now idle. If a
|
|
877
|
+
// replacement turn already occupies the slot, leave it queued — that
|
|
878
|
+
// turn's finally will drain it when it completes.
|
|
879
|
+
if (outcome.kind !== "session-expired" && !getRoomQuery(topicId)) {
|
|
880
|
+
const next = takeDeferredInject(topicId);
|
|
881
|
+
if (next) redispatchInject(next);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return outcome;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Re-run a deferred session-inject after its room freed up. Re-resolves the
|
|
889
|
+
* topic (config/agent may have changed while queued) and dispatches through the
|
|
890
|
+
* same priority path — if the room got busy again, startAiTurn just re-defers.
|
|
891
|
+
*/
|
|
892
|
+
function redispatchInject(inject: DeferredInject): void {
|
|
893
|
+
const topics = getTopics();
|
|
894
|
+
const topic = topics.find((t) => t.id === inject.topicId) as TopicDto | undefined;
|
|
895
|
+
if (!topic?.agent) {
|
|
896
|
+
logger.warn(
|
|
897
|
+
{ topicId: inject.topicId, origin: inject.origin },
|
|
898
|
+
"ai: dropping deferred inject — topic gone or AI no longer invited",
|
|
899
|
+
);
|
|
900
|
+
try {
|
|
901
|
+
inject.onSettled?.({
|
|
902
|
+
queryId: "",
|
|
903
|
+
kind: "error",
|
|
904
|
+
error: "topic gone or AI no longer invited",
|
|
905
|
+
});
|
|
906
|
+
} catch (err) {
|
|
907
|
+
logger.warn({ err, topicId: inject.topicId }, "ai: deferred settlement hook failed");
|
|
908
|
+
}
|
|
909
|
+
if (inject.forkHandle) cleanupAgentFork(inject.forkHandle);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
startAiTurn({
|
|
913
|
+
topic: {
|
|
914
|
+
id: topic.id,
|
|
915
|
+
title: topic.title,
|
|
916
|
+
kind: topic.kind,
|
|
917
|
+
description: topic.description,
|
|
918
|
+
agent: topic.agent,
|
|
919
|
+
defaultModel: topic.defaultModel,
|
|
920
|
+
defaultEffort: topic.defaultEffort,
|
|
921
|
+
aiMode: topic.aiMode,
|
|
922
|
+
aiMention: topic.aiMention,
|
|
923
|
+
},
|
|
924
|
+
userId: inject.userId,
|
|
925
|
+
prompt: inject.prompt,
|
|
926
|
+
allowAutoContinue: false,
|
|
927
|
+
origin: inject.origin,
|
|
928
|
+
onDispatched: inject.onDispatched,
|
|
929
|
+
requestId: inject.requestId,
|
|
930
|
+
depth: inject.depth,
|
|
931
|
+
silent: inject.silent,
|
|
932
|
+
contextId: inject.contextId,
|
|
933
|
+
agentOverride: inject.agentOverride,
|
|
934
|
+
modelOverride: inject.modelOverride,
|
|
935
|
+
effortOverride: inject.effortOverride,
|
|
936
|
+
// Topic-owned turns carry undefined and re-resolve the newest durable
|
|
937
|
+
// session here; isolated/external turns carry their explicit id.
|
|
938
|
+
sessionId: inject.sessionId,
|
|
939
|
+
sessionScope: inject.sessionScope,
|
|
940
|
+
forkHandle: inject.forkHandle,
|
|
941
|
+
prepareSession: inject.prepareSession,
|
|
942
|
+
cwd: inject.cwd,
|
|
943
|
+
sessionName: inject.sessionName,
|
|
944
|
+
sessionType: inject.sessionType,
|
|
945
|
+
onSessionId: inject.onSessionId,
|
|
946
|
+
onSessionReset: inject.onSessionReset,
|
|
947
|
+
bridgeSessionFromHistory: inject.bridgeSessionFromHistory,
|
|
948
|
+
onSettled: inject.onSettled,
|
|
949
|
+
peerBridge: inject.peerBridge,
|
|
950
|
+
askReplySources: inject.askReplySources,
|
|
951
|
+
_runtimeEpoch: inject.runtimeEpoch,
|
|
952
|
+
_sessionRetried: inject._sessionRetried,
|
|
953
|
+
from: inject.from,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
type AskPendingFileRef = {
|
|
958
|
+
requestId?: string;
|
|
959
|
+
contextId?: string;
|
|
960
|
+
callerTopicId?: string;
|
|
961
|
+
callerUserId?: string;
|
|
962
|
+
pendingAsk?: {
|
|
963
|
+
userId: PendingAskUserId;
|
|
964
|
+
from: string;
|
|
965
|
+
to: string;
|
|
966
|
+
requestId?: string;
|
|
967
|
+
};
|
|
968
|
+
remoteReply?: import("#mcp/session-comm/peer-forward").RemoteReplyRoute;
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
async function clearPendingAskFile(
|
|
972
|
+
pending: AskPendingFileRef,
|
|
973
|
+
state?: "reply_ready" | "queued_for_caller" | "injecting_to_caller",
|
|
974
|
+
): Promise<void> {
|
|
975
|
+
if (!pending.pendingAsk) return;
|
|
976
|
+
try {
|
|
977
|
+
const { clearPendingAsk, markPendingAskState } = await import("#storage/session-asks");
|
|
978
|
+
if (state) markPendingAskState({ ...pending.pendingAsk, state });
|
|
979
|
+
clearPendingAsk(pending.pendingAsk);
|
|
980
|
+
} catch (err) {
|
|
981
|
+
logger.warn({ err, pendingAsk: pending.pendingAsk }, "sessions: pending ask cleanup failed");
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function markPendingAskFile(
|
|
986
|
+
pending: AskPendingFileRef,
|
|
987
|
+
state: "reply_ready" | "queued_for_caller" | "injecting_to_caller",
|
|
988
|
+
): Promise<void> {
|
|
989
|
+
if (!pending.pendingAsk) return;
|
|
990
|
+
try {
|
|
991
|
+
const { markPendingAskState } = await import("#storage/session-asks");
|
|
992
|
+
markPendingAskState({ ...pending.pendingAsk, state });
|
|
993
|
+
} catch (err) {
|
|
994
|
+
logger.warn(
|
|
995
|
+
{ err, pendingAsk: pending.pendingAsk, state },
|
|
996
|
+
"sessions: pending ask mark failed",
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** Caller-side injection of an ask reply — delivers the target AI's answer (or
|
|
1002
|
+
* error) back into the caller topic, triggering its AI when one is invited. */
|
|
1003
|
+
export async function deliverAskCallbackToCaller(
|
|
1004
|
+
pending: AskPendingFileRef & {
|
|
1005
|
+
requestId: string;
|
|
1006
|
+
contextId?: string;
|
|
1007
|
+
callerTopicId: string;
|
|
1008
|
+
callerUserId: string;
|
|
1009
|
+
},
|
|
1010
|
+
sourceLabel: string,
|
|
1011
|
+
body: string,
|
|
1012
|
+
kind: "reply" | "error",
|
|
1013
|
+
): Promise<boolean> {
|
|
1014
|
+
if (pending.remoteReply) {
|
|
1015
|
+
const { deliverPeerReply } = await import("#mcp/session-comm/peer-forward");
|
|
1016
|
+
return deliverPeerReply(pending.remoteReply, sourceLabel, body, kind);
|
|
1017
|
+
}
|
|
1018
|
+
const callerTopic = getTopic(pending.callerTopicId);
|
|
1019
|
+
const heading = kind === "error" ? `Error from ${sourceLabel}` : `Reply from ${sourceLabel}`;
|
|
1020
|
+
const prompt = `[${heading}]\n\n${body}`;
|
|
1021
|
+
|
|
1022
|
+
await markPendingAskFile(pending, "reply_ready");
|
|
1023
|
+
|
|
1024
|
+
if (!callerTopic?.agent) {
|
|
1025
|
+
try {
|
|
1026
|
+
appendAskReplyMessage(pending.callerTopicId, prompt);
|
|
1027
|
+
await clearPendingAskFile(pending);
|
|
1028
|
+
return true;
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
logger.warn(
|
|
1031
|
+
{ err, requestId: pending.requestId, callerTopicId: pending.callerTopicId },
|
|
1032
|
+
"sessions: ask callback direct delivery failed",
|
|
1033
|
+
);
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const queued = askReplyInjectBatcher.enqueue({
|
|
1039
|
+
topicId: pending.callerTopicId,
|
|
1040
|
+
userId: pending.callerUserId,
|
|
1041
|
+
prompt,
|
|
1042
|
+
origin: sourceLabel,
|
|
1043
|
+
requestId: pending.requestId,
|
|
1044
|
+
contextId: pending.contextId,
|
|
1045
|
+
sessionId: getTopicSessionId(pending.callerTopicId),
|
|
1046
|
+
askReplySources: [
|
|
1047
|
+
{ from: sourceLabel, requestId: pending.requestId, contextId: pending.contextId },
|
|
1048
|
+
],
|
|
1049
|
+
onDispatched: (queryId: string) => {
|
|
1050
|
+
void clearPendingAskFile(pending, "injecting_to_caller");
|
|
1051
|
+
logger.info(
|
|
1052
|
+
{
|
|
1053
|
+
requestId: pending.requestId,
|
|
1054
|
+
callerTopicId: pending.callerTopicId,
|
|
1055
|
+
callerQueryId: queryId,
|
|
1056
|
+
source: sourceLabel,
|
|
1057
|
+
},
|
|
1058
|
+
"sessions: batched ask callback dispatched to caller",
|
|
1059
|
+
);
|
|
1060
|
+
},
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
if (queued) {
|
|
1064
|
+
// Keep individual replies visible in topic history; only the model-facing
|
|
1065
|
+
// caller turn is coalesced into one prompt.
|
|
1066
|
+
appendAskReplyMessage(pending.callerTopicId, prompt, callerTopic.agent);
|
|
1067
|
+
await markPendingAskFile(pending, "queued_for_caller");
|
|
1068
|
+
return true;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// A replay can encounter the same request while its original callback is
|
|
1072
|
+
// still queued. That request is already owned; do not append it twice.
|
|
1073
|
+
if (interSessionQueue.hasRequest(pending.callerTopicId, pending.requestId)) {
|
|
1074
|
+
await markPendingAskFile(pending, "queued_for_caller");
|
|
1075
|
+
return true;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
logger.warn(
|
|
1079
|
+
{ requestId: pending.requestId, callerTopicId: pending.callerTopicId, source: sourceLabel },
|
|
1080
|
+
"sessions: ask callback could not enter caller batch; appending direct fallback",
|
|
1081
|
+
);
|
|
1082
|
+
appendAskReplyMessage(pending.callerTopicId, prompt, callerTopic.agent);
|
|
1083
|
+
await clearPendingAskFile(pending);
|
|
1084
|
+
return true;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Deliver an ask callback error to the caller topic (in-process, no-op if no pending ask). */
|
|
1088
|
+
async function deliverAskError(queryId: string, sourceLabel: string, error: string) {
|
|
1089
|
+
try {
|
|
1090
|
+
const { resolveAskCallback } = await import("#runtime/ask-callbacks");
|
|
1091
|
+
const pending = resolveAskCallback(queryId);
|
|
1092
|
+
if (pending) {
|
|
1093
|
+
const prefix = pending.timedOut ? "⚠️ [Timeout - error notification was dropped]\n\n" : "";
|
|
1094
|
+
await deliverAskCallbackToCaller(pending, sourceLabel, `${prefix}${error}`, "error");
|
|
1095
|
+
}
|
|
1096
|
+
} catch {
|
|
1097
|
+
// Best-effort — don't let callback failure crash the main agent loop.
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function tryReconstructTopicRollout(opts: {
|
|
1102
|
+
topicId: string;
|
|
1103
|
+
topicTitle: string;
|
|
1104
|
+
userId: string;
|
|
1105
|
+
agent: AgentKind;
|
|
1106
|
+
sessionId: string | null | undefined;
|
|
1107
|
+
cwd: string;
|
|
1108
|
+
model: string;
|
|
1109
|
+
effort?: EffortLevel;
|
|
1110
|
+
allowFreshSession?: boolean;
|
|
1111
|
+
}): string | null {
|
|
1112
|
+
const { topicId, topicTitle, userId, agent, sessionId, cwd, model, effort } = opts;
|
|
1113
|
+
if (!sessionId && !opts.allowFreshSession) return null;
|
|
1114
|
+
|
|
1115
|
+
try {
|
|
1116
|
+
const entries = readConversation(userId, topicTitle);
|
|
1117
|
+
if (entries.length === 0) return null;
|
|
1118
|
+
const result = getRegistry(agent).writeRollout({
|
|
1119
|
+
cwd,
|
|
1120
|
+
entries,
|
|
1121
|
+
model,
|
|
1122
|
+
...(effort ? { effort } : {}),
|
|
1123
|
+
...(sessionId ? { reuseSessionId: sessionId } : {}),
|
|
1124
|
+
});
|
|
1125
|
+
logger.info(
|
|
1126
|
+
{
|
|
1127
|
+
topicId,
|
|
1128
|
+
agent,
|
|
1129
|
+
sessionId: result.sessionId,
|
|
1130
|
+
rolloutPath: result.rolloutPath,
|
|
1131
|
+
entries: entries.length,
|
|
1132
|
+
},
|
|
1133
|
+
sessionId
|
|
1134
|
+
? "ai: session expired — rollout reconstructed from unified log"
|
|
1135
|
+
: "ai: provider session bridged from shared conversation log",
|
|
1136
|
+
);
|
|
1137
|
+
return result.sessionId;
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
logger.warn({ err, topicId, agent, sessionId }, "ai: session reconstruct failed");
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function resolveSessionRetryId(opts: {
|
|
1145
|
+
topicId: string;
|
|
1146
|
+
topicTitle: string;
|
|
1147
|
+
userId: string;
|
|
1148
|
+
agent: AgentKind;
|
|
1149
|
+
sessionId: string | null | undefined;
|
|
1150
|
+
cwd: string;
|
|
1151
|
+
silent: boolean;
|
|
1152
|
+
model: string;
|
|
1153
|
+
effort?: EffortLevel;
|
|
1154
|
+
externalSessionOwner?: boolean;
|
|
1155
|
+
onSessionReset?: () => void;
|
|
1156
|
+
}): string | null {
|
|
1157
|
+
const reconstructed = tryReconstructTopicRollout(opts);
|
|
1158
|
+
if (reconstructed) return reconstructed;
|
|
1159
|
+
if (opts.onSessionReset) opts.onSessionReset();
|
|
1160
|
+
else if (!opts.silent && !opts.externalSessionOwner) {
|
|
1161
|
+
clearTopicSessionId(opts.topicId, "session-expired");
|
|
1162
|
+
}
|
|
1163
|
+
logger.info(
|
|
1164
|
+
{ topicId: opts.topicId, agent: opts.agent, hadSessionId: Boolean(opts.sessionId) },
|
|
1165
|
+
"ai: session expired — retrying with fresh session",
|
|
1166
|
+
);
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
export interface AiTurnTopic {
|
|
1171
|
+
id: string;
|
|
1172
|
+
title: string;
|
|
1173
|
+
kind?: TopicDto["kind"];
|
|
1174
|
+
description?: string | null;
|
|
1175
|
+
agent?: AgentKind | null;
|
|
1176
|
+
defaultModel?: string;
|
|
1177
|
+
defaultEffort?: EffortLevel;
|
|
1178
|
+
aiMode?: TopicDto["aiMode"];
|
|
1179
|
+
aiMention?: boolean;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
export interface AiTurnSettlement {
|
|
1183
|
+
queryId: string;
|
|
1184
|
+
kind: "completed" | "aborted" | "error";
|
|
1185
|
+
/** Distinguishes user preemption from an explicit stop for durable owners. */
|
|
1186
|
+
abortReason?: AbortReason;
|
|
1187
|
+
error?: string;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Execution controls shared by direct and injected turns. */
|
|
1191
|
+
export interface AiTurnExecutionOptions {
|
|
1192
|
+
attachments?: string[];
|
|
1193
|
+
/** "user" (default) for a human message; otherwise the inject source topic. */
|
|
1194
|
+
origin?: string;
|
|
1195
|
+
/** Fired when the turn is actually dispatched, including after a defer. */
|
|
1196
|
+
onDispatched?: (queryId: string) => void;
|
|
1197
|
+
/** Inter-session requestId for queue dedup (session-inject only). */
|
|
1198
|
+
requestId?: string;
|
|
1199
|
+
/** Nesting depth/context metadata preserved across defer/requeue. */
|
|
1200
|
+
depth?: number;
|
|
1201
|
+
silent?: boolean;
|
|
1202
|
+
contextId?: string;
|
|
1203
|
+
modelOverride?: string;
|
|
1204
|
+
effortOverride?: EffortLevel;
|
|
1205
|
+
sessionId?: string | null;
|
|
1206
|
+
/** Explicitly isolate a provider conversation from the topic's durable session. */
|
|
1207
|
+
sessionScope?: "topic" | "isolated";
|
|
1208
|
+
forkHandle?: ForkHandle;
|
|
1209
|
+
/** Lazily create an isolated provider session immediately before execution. */
|
|
1210
|
+
prepareSession?: DeferredInject["prepareSession"];
|
|
1211
|
+
cwd?: string;
|
|
1212
|
+
/** Provider conversation namespace. Defaults to the visible topic title. */
|
|
1213
|
+
sessionName?: string;
|
|
1214
|
+
/** Tool/catalog scope. Defaults to manager or forum based on topic kind. */
|
|
1215
|
+
sessionType?: "dm" | "forum" | "ephemeral" | "manager" | "cron";
|
|
1216
|
+
/** Own this turn's provider session without replacing the topic's main session. */
|
|
1217
|
+
onSessionId?: (sessionId: string) => void;
|
|
1218
|
+
/** Clear the externally-owned provider session after recovery fails. */
|
|
1219
|
+
onSessionReset?: () => void;
|
|
1220
|
+
/** Bridge a fresh provider session from this namespace's shared conversation log. */
|
|
1221
|
+
bridgeSessionFromHistory?: boolean;
|
|
1222
|
+
/** Observe the final non-retry outcome of this turn. */
|
|
1223
|
+
onSettled?: (result: AiTurnSettlement) => void;
|
|
1224
|
+
/** Route user-facing runtime mutations back to a placed room's hub. */
|
|
1225
|
+
peerBridge?: PeerRuntimeBridgeContext;
|
|
1226
|
+
askReplySources?: DeferredInject["askReplySources"];
|
|
1227
|
+
/** Internal source marker, e.g. FROM_AUTO_CONTINUE for config-change resumes. */
|
|
1228
|
+
from?: string;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
export interface StartAiTurnParams extends AiTurnExecutionOptions {
|
|
1232
|
+
topic: AiTurnTopic;
|
|
1233
|
+
userId: string;
|
|
1234
|
+
prompt: string;
|
|
1235
|
+
allowAutoContinue: boolean;
|
|
1236
|
+
agentOverride?: AgentKind;
|
|
1237
|
+
/** Stable query id reserved before a cross-process handoff. Internal only. */
|
|
1238
|
+
_queryId?: string;
|
|
1239
|
+
/** Topic epoch captured when queued. Internal reset-fence guard only. */
|
|
1240
|
+
_runtimeEpoch?: number;
|
|
1241
|
+
_sessionRetried?: boolean;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
export interface TriggerTopicAiTurnOptions extends AiTurnExecutionOptions {
|
|
1245
|
+
/** Run the injected turn without writing the inject prompt as a visible message. */
|
|
1246
|
+
hideInjectMessage?: boolean;
|
|
1247
|
+
/** Visible injected-message author. Execution still runs as `userId`. */
|
|
1248
|
+
injectAuthorId?: string;
|
|
1249
|
+
/** Identifies the subsystem that created the visible injected message. */
|
|
1250
|
+
injectSourceAdapter?: string;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
export interface ResolvedTopicTurnExecution {
|
|
1254
|
+
agent: AgentKind;
|
|
1255
|
+
model: string;
|
|
1256
|
+
effort?: EffortLevel;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** One canonical resolver for provider execution and user-visible metadata. */
|
|
1260
|
+
export function resolveTopicTurnExecution(
|
|
1261
|
+
topic: AiTurnTopic,
|
|
1262
|
+
overrides: Pick<AiTurnExecutionOptions, "modelOverride" | "effortOverride"> & {
|
|
1263
|
+
agentOverride?: AgentKind;
|
|
1264
|
+
} = {},
|
|
1265
|
+
): ResolvedTopicTurnExecution {
|
|
1266
|
+
const config = getTopicConfig(topic.id);
|
|
1267
|
+
const agent = (overrides.agentOverride ?? topic.agent ?? "maestro") as AgentKind;
|
|
1268
|
+
const registry = getRegistry(agent);
|
|
1269
|
+
const usesTopicDefaults = !overrides.agentOverride || overrides.agentOverride === topic.agent;
|
|
1270
|
+
const model = resolveModelForAgent(
|
|
1271
|
+
agent,
|
|
1272
|
+
overrides.modelOverride ??
|
|
1273
|
+
(usesTopicDefaults ? (config?.model ?? topic.defaultModel) : undefined),
|
|
1274
|
+
registry,
|
|
1275
|
+
);
|
|
1276
|
+
const requestedEffort = (overrides.effortOverride ??
|
|
1277
|
+
(usesTopicDefaults ? (config?.effort ?? topic.defaultEffort) : undefined)) as
|
|
1278
|
+
| EffortLevel
|
|
1279
|
+
| undefined;
|
|
1280
|
+
const effort =
|
|
1281
|
+
requestedEffort && registry.validateEffort(requestedEffort)
|
|
1282
|
+
? requestedEffort
|
|
1283
|
+
: registry.defaultEffort;
|
|
1284
|
+
return { agent, model, ...(effort ? { effort } : {}) };
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
export interface ResolvedTopicTurnSession {
|
|
1288
|
+
sessionId: string | null | undefined;
|
|
1289
|
+
isolated: boolean;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Resolve topic-session ownership from execution compatibility, rather than
|
|
1294
|
+
* asking each adapter to remember when a provider session is safe to reuse.
|
|
1295
|
+
*/
|
|
1296
|
+
export function resolveTopicTurnSession(
|
|
1297
|
+
topic: AiTurnTopic,
|
|
1298
|
+
requestedSessionId: string | null | undefined,
|
|
1299
|
+
options: Pick<
|
|
1300
|
+
AiTurnExecutionOptions,
|
|
1301
|
+
"silent" | "sessionScope" | "sessionName" | "sessionType" | "modelOverride" | "effortOverride"
|
|
1302
|
+
> & {
|
|
1303
|
+
agentOverride?: AgentKind;
|
|
1304
|
+
hasFork?: boolean;
|
|
1305
|
+
preparesSession?: boolean;
|
|
1306
|
+
externalSessionOwner?: boolean;
|
|
1307
|
+
} = {},
|
|
1308
|
+
): ResolvedTopicTurnSession {
|
|
1309
|
+
const main = resolveTopicTurnExecution(topic);
|
|
1310
|
+
const requested = resolveTopicTurnExecution(topic, options);
|
|
1311
|
+
const incompatibleWithMain = requested.agent !== main.agent || requested.model !== main.model;
|
|
1312
|
+
const alternateNamespace =
|
|
1313
|
+
(options.sessionName !== undefined && options.sessionName !== topic.title) ||
|
|
1314
|
+
options.sessionType === "cron";
|
|
1315
|
+
const isolated = Boolean(
|
|
1316
|
+
options.sessionScope === "isolated" ||
|
|
1317
|
+
options.silent ||
|
|
1318
|
+
options.hasFork ||
|
|
1319
|
+
options.preparesSession ||
|
|
1320
|
+
options.externalSessionOwner ||
|
|
1321
|
+
incompatibleWithMain ||
|
|
1322
|
+
alternateNamespace,
|
|
1323
|
+
);
|
|
1324
|
+
return {
|
|
1325
|
+
sessionId: resolveInitialTurnSessionId(topic.id, requestedSessionId, isolated),
|
|
1326
|
+
isolated,
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* Resolve the provider resume key for a new turn. Direct user/channel turns
|
|
1332
|
+
* inherit the topic's durable session unless a caller explicitly supplies a
|
|
1333
|
+
* key (including null for an intentional fresh start). Isolated ask/cron
|
|
1334
|
+
* sessions remain owned by their caller and never borrow the main topic key.
|
|
1335
|
+
*/
|
|
1336
|
+
export function resolveInitialTurnSessionId(
|
|
1337
|
+
topicId: string,
|
|
1338
|
+
requestedSessionId: string | null | undefined,
|
|
1339
|
+
isolated: boolean,
|
|
1340
|
+
): string | null | undefined {
|
|
1341
|
+
if (requestedSessionId !== undefined) return requestedSessionId;
|
|
1342
|
+
return isolated ? undefined : getTopicSessionId(topicId);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const remoteInjectWaiters = new Map<string, ReturnType<typeof setInterval>>();
|
|
1346
|
+
|
|
1347
|
+
function serializableUserTurnExecution(params: StartAiTurnParams): RuntimeUserTurnExecution {
|
|
1348
|
+
return {
|
|
1349
|
+
runtimeEpoch: params._runtimeEpoch ?? getRuntimeTopicEpoch(params.topic.id),
|
|
1350
|
+
sourceRequestId: params.requestId,
|
|
1351
|
+
agentOverride: params.agentOverride,
|
|
1352
|
+
modelOverride: params.modelOverride,
|
|
1353
|
+
effortOverride: params.effortOverride,
|
|
1354
|
+
sessionId: params.sessionId,
|
|
1355
|
+
sessionIdSpecified: params.sessionId !== undefined,
|
|
1356
|
+
sessionScope: params.sessionScope,
|
|
1357
|
+
cwd: params.cwd,
|
|
1358
|
+
sessionName: params.sessionName,
|
|
1359
|
+
sessionType: params.sessionType,
|
|
1360
|
+
bridgeSessionFromHistory: params.bridgeSessionFromHistory,
|
|
1361
|
+
peerBridge: params.peerBridge,
|
|
1362
|
+
from: params.from,
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function waitToStartRemoteUserTurn(params: StartAiTurnParams, queryId: string): string {
|
|
1367
|
+
const previous = getRuntimeUserTurnRequest(params.topic.id);
|
|
1368
|
+
const execution = serializableUserTurnExecution(params);
|
|
1369
|
+
const queuedQueryId = enqueueRuntimeUserTurnRequest({
|
|
1370
|
+
topicId: params.topic.id,
|
|
1371
|
+
userId: params.userId,
|
|
1372
|
+
prompt: params.prompt,
|
|
1373
|
+
attachments: params.attachments,
|
|
1374
|
+
allowAutoContinue: params.allowAutoContinue,
|
|
1375
|
+
requestId: queryId,
|
|
1376
|
+
execution,
|
|
1377
|
+
topicEpoch: execution.runtimeEpoch,
|
|
1378
|
+
});
|
|
1379
|
+
if (previous && previous.requestId !== queuedQueryId) {
|
|
1380
|
+
WsHub.get().broadcastAborted(params.topic.id, previous.requestId, "superseded");
|
|
1381
|
+
}
|
|
1382
|
+
return queuedQueryId;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function announceQueuedUserTurn(params: StartAiTurnParams, queryId: string): void {
|
|
1386
|
+
try {
|
|
1387
|
+
params.onDispatched?.(queryId);
|
|
1388
|
+
} catch (err) {
|
|
1389
|
+
logger.warn({ err, topicId: params.topic.id, queryId }, "ai: queued dispatch hook failed");
|
|
1390
|
+
}
|
|
1391
|
+
if (!params.silent) WsHub.get().broadcastAiActive(params.topic.id, queryId);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function waitToDrainRemoteInject(topicId: string): void {
|
|
1395
|
+
if (remoteInjectWaiters.has(topicId)) return;
|
|
1396
|
+
const timer = setInterval(() => {
|
|
1397
|
+
if (
|
|
1398
|
+
getRuntimeTurnLease(topicId) ||
|
|
1399
|
+
getRoomQuery(topicId) ||
|
|
1400
|
+
isRuntimeTopicMaintenance(topicId)
|
|
1401
|
+
) {
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
clearInterval(timer);
|
|
1405
|
+
remoteInjectWaiters.delete(topicId);
|
|
1406
|
+
const next = takeDeferredInject(topicId);
|
|
1407
|
+
if (next) redispatchInject(next);
|
|
1408
|
+
}, 100);
|
|
1409
|
+
timer.unref?.();
|
|
1410
|
+
remoteInjectWaiters.set(topicId, timer);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
let durableTurnWorker: ReturnType<typeof setInterval> | null = null;
|
|
1414
|
+
let durableTurnWorkerBusy = false;
|
|
1415
|
+
|
|
1416
|
+
async function drainOneDurableUserTurn(): Promise<void> {
|
|
1417
|
+
if (durableTurnWorkerBusy) return;
|
|
1418
|
+
durableTurnWorkerBusy = true;
|
|
1419
|
+
try {
|
|
1420
|
+
const request = claimNextRuntimeUserTurnRequest(RUNTIME_INSTANCE_ID);
|
|
1421
|
+
if (!request) return;
|
|
1422
|
+
const topic = getTopic(request.topicId);
|
|
1423
|
+
if (!topic?.agent) {
|
|
1424
|
+
completeRuntimeUserTurnRequest(request.topicId, request.requestId);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
const execution = request.execution;
|
|
1428
|
+
const queryId = startAiTurn({
|
|
1429
|
+
topic,
|
|
1430
|
+
userId: request.userId,
|
|
1431
|
+
prompt: request.prompt,
|
|
1432
|
+
attachments: request.attachments,
|
|
1433
|
+
allowAutoContinue: request.allowAutoContinue,
|
|
1434
|
+
origin: "user",
|
|
1435
|
+
requestId: execution?.sourceRequestId,
|
|
1436
|
+
agentOverride: execution?.agentOverride,
|
|
1437
|
+
modelOverride: execution?.modelOverride,
|
|
1438
|
+
effortOverride: execution?.effortOverride,
|
|
1439
|
+
...(execution?.sessionIdSpecified ? { sessionId: execution.sessionId } : {}),
|
|
1440
|
+
sessionScope: execution?.sessionScope,
|
|
1441
|
+
cwd: execution?.cwd,
|
|
1442
|
+
sessionName: execution?.sessionName,
|
|
1443
|
+
sessionType: execution?.sessionType,
|
|
1444
|
+
bridgeSessionFromHistory: execution?.bridgeSessionFromHistory,
|
|
1445
|
+
peerBridge: execution?.peerBridge,
|
|
1446
|
+
from: execution?.from,
|
|
1447
|
+
_queryId: request.requestId,
|
|
1448
|
+
_runtimeEpoch: execution?.runtimeEpoch ?? request.topicEpoch,
|
|
1449
|
+
onSettled: () => {
|
|
1450
|
+
completeRuntimeUserTurnRequest(request.topicId, request.requestId);
|
|
1451
|
+
},
|
|
1452
|
+
});
|
|
1453
|
+
if (!queryId) {
|
|
1454
|
+
releaseRuntimeUserTurnClaim(request.topicId, request.requestId, RUNTIME_INSTANCE_ID);
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
markRuntimeUserTurnRunning(request.topicId, request.requestId, RUNTIME_INSTANCE_ID, queryId);
|
|
1458
|
+
} finally {
|
|
1459
|
+
durableTurnWorkerBusy = false;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/** Start a process-local claimant for durable user turns. Multiple processes
|
|
1464
|
+
* may run this worker; SQLite claims and topic leases choose exactly one. */
|
|
1465
|
+
export function startDurableTurnRequestWorker(): () => void {
|
|
1466
|
+
if (durableTurnWorker) return () => {};
|
|
1467
|
+
durableTurnWorker = setInterval(() => {
|
|
1468
|
+
void drainOneDurableUserTurn();
|
|
1469
|
+
}, 100);
|
|
1470
|
+
durableTurnWorker.unref?.();
|
|
1471
|
+
void drainOneDurableUserTurn();
|
|
1472
|
+
return () => {
|
|
1473
|
+
if (!durableTurnWorker) return;
|
|
1474
|
+
clearInterval(durableTurnWorker);
|
|
1475
|
+
durableTurnWorker = null;
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* Start one AI turn for a topic: resolve agent/model/effort from
|
|
1481
|
+
* `config override > topic default`, run the agent, and stream events. Returns
|
|
1482
|
+
* the queryId.
|
|
1483
|
+
*
|
|
1484
|
+
* Caller guarantees the topic has an AI participant (agent set) —
|
|
1485
|
+
* people-only rooms are rejected upstream, so self-config / AI never
|
|
1486
|
+
* attach there.
|
|
1487
|
+
*
|
|
1488
|
+
* `allowAutoContinue`: when the AI changes this topic's config mid-turn via
|
|
1489
|
+
* runtime MCP, allow the runtime MCP context to enqueue ONE continue
|
|
1490
|
+
* turn. Follow-up turns pass false, so config changes cannot recurse.
|
|
1491
|
+
*/
|
|
1492
|
+
export function startAiTurn(params: StartAiTurnParams): string | null {
|
|
1493
|
+
// A caller may have retained an old DTO while another surface changed the
|
|
1494
|
+
// topic. Durable state is authoritative at the execution boundary.
|
|
1495
|
+
const storedTopic = getTopic(params.topic.id);
|
|
1496
|
+
if (!storedTopic || (!storedTopic.agent && !params.agentOverride)) {
|
|
1497
|
+
if (params.forkHandle) cleanupAgentFork(params.forkHandle);
|
|
1498
|
+
try {
|
|
1499
|
+
params.onSettled?.({
|
|
1500
|
+
queryId: "",
|
|
1501
|
+
kind: "error",
|
|
1502
|
+
error: storedTopic ? "topic no longer has an AI agent" : "topic no longer exists",
|
|
1503
|
+
});
|
|
1504
|
+
} catch (err) {
|
|
1505
|
+
logger.warn({ err, topicId: params.topic.id }, "ai: rejected-turn settlement hook failed");
|
|
1506
|
+
}
|
|
1507
|
+
return null;
|
|
1508
|
+
}
|
|
1509
|
+
const topic: TopicDto = storedTopic;
|
|
1510
|
+
const { userId, allowAutoContinue, onDispatched } = params;
|
|
1511
|
+
const prompt = params.prompt;
|
|
1512
|
+
const attachments = params.attachments;
|
|
1513
|
+
const execution = resolveTopicTurnExecution(topic, params);
|
|
1514
|
+
const sessionResolution = resolveTopicTurnSession(topic, params.sessionId, {
|
|
1515
|
+
agentOverride: params.agentOverride,
|
|
1516
|
+
modelOverride: params.modelOverride,
|
|
1517
|
+
effortOverride: params.effortOverride,
|
|
1518
|
+
silent: params.silent,
|
|
1519
|
+
sessionScope: params.sessionScope,
|
|
1520
|
+
sessionName: params.sessionName,
|
|
1521
|
+
sessionType: params.sessionType,
|
|
1522
|
+
hasFork: Boolean(params.forkHandle),
|
|
1523
|
+
preparesSession: Boolean(params.prepareSession),
|
|
1524
|
+
externalSessionOwner: Boolean(params.onSessionId),
|
|
1525
|
+
});
|
|
1526
|
+
let sessionId = sessionResolution.sessionId;
|
|
1527
|
+
// Topic-owned sessions are deliberately re-resolved after a defer. Isolated
|
|
1528
|
+
// and explicit sessions retain the id supplied by their owner.
|
|
1529
|
+
const deferredSessionId =
|
|
1530
|
+
params.sessionId === undefined && !sessionResolution.isolated ? undefined : sessionId;
|
|
1531
|
+
const origin = params.origin ?? "user";
|
|
1532
|
+
const topicId = topic.id;
|
|
1533
|
+
const requestId = params.requestId;
|
|
1534
|
+
const depth = params.depth;
|
|
1535
|
+
const silent = params.silent;
|
|
1536
|
+
const contextId = params.contextId;
|
|
1537
|
+
const agentOverride = params.agentOverride;
|
|
1538
|
+
const modelOverride = params.modelOverride;
|
|
1539
|
+
const effortOverride = params.effortOverride;
|
|
1540
|
+
const sessionScope = params.sessionScope;
|
|
1541
|
+
let forkHandle = params.forkHandle;
|
|
1542
|
+
const prepareSession = params.prepareSession;
|
|
1543
|
+
const cwd = params.cwd;
|
|
1544
|
+
const sessionName = params.sessionName ?? topic.title;
|
|
1545
|
+
const sessionType = params.sessionType;
|
|
1546
|
+
const onSessionId = params.onSessionId;
|
|
1547
|
+
const onSessionReset = params.onSessionReset;
|
|
1548
|
+
const bridgeSessionFromHistory = params.bridgeSessionFromHistory === true;
|
|
1549
|
+
const onSettled = params.onSettled;
|
|
1550
|
+
const peerBridge = params.peerBridge;
|
|
1551
|
+
const askReplySources = params.askReplySources;
|
|
1552
|
+
const sessionRetried = params._sessionRetried === true;
|
|
1553
|
+
const queryId = params._queryId ?? randomUUID();
|
|
1554
|
+
const currentRuntimeEpoch = getRuntimeTopicEpoch(topic.id);
|
|
1555
|
+
const runtimeEpoch = params._runtimeEpoch ?? currentRuntimeEpoch;
|
|
1556
|
+
if (params._runtimeEpoch !== undefined && params._runtimeEpoch !== currentRuntimeEpoch) {
|
|
1557
|
+
if (params.forkHandle) cleanupAgentFork(params.forkHandle);
|
|
1558
|
+
if (!params.silent && params._queryId) {
|
|
1559
|
+
WsHub.get().broadcastAborted(topic.id, params._queryId, "stopped");
|
|
1560
|
+
}
|
|
1561
|
+
try {
|
|
1562
|
+
params.onSettled?.({ queryId, kind: "aborted", abortReason: AbortReason.External });
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
logger.warn({ err, topicId: topic.id, queryId }, "ai: stale-turn settlement hook failed");
|
|
1565
|
+
}
|
|
1566
|
+
logger.info(
|
|
1567
|
+
{ topicId: topic.id, queryId, queuedEpoch: params._runtimeEpoch, currentRuntimeEpoch },
|
|
1568
|
+
"ai: dropped work queued before topic reset",
|
|
1569
|
+
);
|
|
1570
|
+
return null;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
const deferCurrentTurn = (): boolean => {
|
|
1574
|
+
const queued = deferInject({
|
|
1575
|
+
topicId,
|
|
1576
|
+
runtimeEpoch,
|
|
1577
|
+
userId,
|
|
1578
|
+
prompt,
|
|
1579
|
+
origin,
|
|
1580
|
+
requestId,
|
|
1581
|
+
depth,
|
|
1582
|
+
silent,
|
|
1583
|
+
contextId,
|
|
1584
|
+
agentOverride,
|
|
1585
|
+
modelOverride,
|
|
1586
|
+
effortOverride,
|
|
1587
|
+
sessionId: deferredSessionId,
|
|
1588
|
+
sessionScope,
|
|
1589
|
+
forkHandle,
|
|
1590
|
+
prepareSession,
|
|
1591
|
+
cwd,
|
|
1592
|
+
sessionName,
|
|
1593
|
+
sessionType,
|
|
1594
|
+
onSessionId,
|
|
1595
|
+
onSessionReset,
|
|
1596
|
+
bridgeSessionFromHistory,
|
|
1597
|
+
onSettled,
|
|
1598
|
+
peerBridge,
|
|
1599
|
+
askReplySources,
|
|
1600
|
+
_sessionRetried: sessionRetried,
|
|
1601
|
+
onDispatched,
|
|
1602
|
+
from: params.from,
|
|
1603
|
+
});
|
|
1604
|
+
if (!queued && forkHandle) cleanupAgentFork(forkHandle);
|
|
1605
|
+
return queued;
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
// Abort-on-new-message priority (Otium handler.ts L175-208). At most one
|
|
1609
|
+
// in-flight turn per room: a user message preempts whatever is running; a
|
|
1610
|
+
// session-inject waits its turn behind the user.
|
|
1611
|
+
const decision = decideNewQuery(topicId, origin);
|
|
1612
|
+
if (decision.action === "defer") {
|
|
1613
|
+
deferCurrentTurn();
|
|
1614
|
+
logger.info({ topicId, origin }, "ai: session-inject deferred behind running turn");
|
|
1615
|
+
return null;
|
|
1616
|
+
}
|
|
1617
|
+
if (decision.action === "remote-defer") {
|
|
1618
|
+
if (deferCurrentTurn()) waitToDrainRemoteInject(topicId);
|
|
1619
|
+
logger.info(
|
|
1620
|
+
{ topicId, origin, remoteQueryId: decision.running.queryId },
|
|
1621
|
+
"ai: session-inject deferred behind a turn owned by another process",
|
|
1622
|
+
);
|
|
1623
|
+
return null;
|
|
1624
|
+
}
|
|
1625
|
+
if (decision.action === "remote-abort-wait") {
|
|
1626
|
+
requestRuntimeTurnAbort(topicId, "internal");
|
|
1627
|
+
const queuedQueryId = waitToStartRemoteUserTurn(
|
|
1628
|
+
{ ...params, topic, _runtimeEpoch: runtimeEpoch },
|
|
1629
|
+
queryId,
|
|
1630
|
+
);
|
|
1631
|
+
announceQueuedUserTurn({ ...params, topic }, queuedQueryId);
|
|
1632
|
+
logger.info(
|
|
1633
|
+
{ topicId, queryId: queuedQueryId, remoteQueryId: decision.running.queryId },
|
|
1634
|
+
"ai: user turn waiting for another process to release the topic lease",
|
|
1635
|
+
);
|
|
1636
|
+
return queuedQueryId;
|
|
1637
|
+
}
|
|
1638
|
+
if (decision.action === "abort-replace") {
|
|
1639
|
+
const running = decision.running;
|
|
1640
|
+
// The preempted turn was itself a session-inject → re-queue it so the
|
|
1641
|
+
// inter-session work isn't lost; it resumes after the user's turn.
|
|
1642
|
+
if (running.injectParams) {
|
|
1643
|
+
const runningInject = running.injectParams;
|
|
1644
|
+
// A silent ask fork may already contain a partially-consumed provider
|
|
1645
|
+
// rollout. Requeue only its recipe, never that mutable rollout; the old
|
|
1646
|
+
// turn's finally block owns cleanup and the replay prepares a fresh fork.
|
|
1647
|
+
const requeuedInject = runningInject.prepareSession
|
|
1648
|
+
? { ...runningInject, sessionId: undefined, forkHandle: undefined }
|
|
1649
|
+
: runningInject;
|
|
1650
|
+
const queued = deferInject(requeuedInject);
|
|
1651
|
+
running.injectRequeued = queued;
|
|
1652
|
+
if (queued && runningInject.prepareSession) {
|
|
1653
|
+
// The queued copy retained requestId, so detach it from the dying
|
|
1654
|
+
// control. The async preparation may still publish an old fork after
|
|
1655
|
+
// this abort; finally must clean that fork instead of mistaking it for
|
|
1656
|
+
// the queued replay's resource.
|
|
1657
|
+
running.injectParams = { ...runningInject, requestId: undefined };
|
|
1658
|
+
} else if (!queued && runningInject.forkHandle) {
|
|
1659
|
+
cleanupAgentFork(runningInject.forkHandle);
|
|
1660
|
+
running.injectParams = {
|
|
1661
|
+
...runningInject,
|
|
1662
|
+
forkHandle: undefined,
|
|
1663
|
+
sessionId: undefined,
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
running.abortReason = AbortReason.Internal;
|
|
1668
|
+
running.abortController.abort();
|
|
1669
|
+
logger.info(
|
|
1670
|
+
{ topicId, supersededQueryId: running.queryId, supersededOrigin: running.origin },
|
|
1671
|
+
"ai: new user message superseded running turn",
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const abortController = new AbortController();
|
|
1676
|
+
const control: RoomQueryControl = {
|
|
1677
|
+
topicId,
|
|
1678
|
+
queryId,
|
|
1679
|
+
origin,
|
|
1680
|
+
prompt,
|
|
1681
|
+
attachments,
|
|
1682
|
+
sessionId,
|
|
1683
|
+
abortController,
|
|
1684
|
+
abortReason: AbortReason.None,
|
|
1685
|
+
// Preserve enough to re-queue this turn if a later user message preempts it.
|
|
1686
|
+
injectParams: isUserOrigin(origin)
|
|
1687
|
+
? undefined
|
|
1688
|
+
: {
|
|
1689
|
+
topicId,
|
|
1690
|
+
runtimeEpoch,
|
|
1691
|
+
userId,
|
|
1692
|
+
prompt,
|
|
1693
|
+
origin,
|
|
1694
|
+
requestId,
|
|
1695
|
+
depth,
|
|
1696
|
+
silent,
|
|
1697
|
+
contextId,
|
|
1698
|
+
agentOverride,
|
|
1699
|
+
modelOverride,
|
|
1700
|
+
effortOverride,
|
|
1701
|
+
sessionId: deferredSessionId,
|
|
1702
|
+
sessionScope,
|
|
1703
|
+
forkHandle,
|
|
1704
|
+
prepareSession,
|
|
1705
|
+
cwd,
|
|
1706
|
+
sessionName,
|
|
1707
|
+
sessionType,
|
|
1708
|
+
onSessionId,
|
|
1709
|
+
onSessionReset,
|
|
1710
|
+
bridgeSessionFromHistory,
|
|
1711
|
+
onSettled,
|
|
1712
|
+
peerBridge,
|
|
1713
|
+
askReplySources,
|
|
1714
|
+
_sessionRetried: sessionRetried,
|
|
1715
|
+
onDispatched,
|
|
1716
|
+
from: params.from,
|
|
1717
|
+
},
|
|
1718
|
+
};
|
|
1719
|
+
if (!setRoomQuery(control)) {
|
|
1720
|
+
// Another process won the lease between the decision and the atomic claim.
|
|
1721
|
+
// Preserve the same user-priority behavior and retry after that owner
|
|
1722
|
+
// releases (or its heartbeat expires).
|
|
1723
|
+
if (isUserOrigin(origin)) {
|
|
1724
|
+
requestRuntimeTurnAbort(topicId, "internal");
|
|
1725
|
+
const queuedQueryId = waitToStartRemoteUserTurn(
|
|
1726
|
+
{ ...params, topic, _runtimeEpoch: runtimeEpoch },
|
|
1727
|
+
queryId,
|
|
1728
|
+
);
|
|
1729
|
+
announceQueuedUserTurn({ ...params, topic }, queuedQueryId);
|
|
1730
|
+
return queuedQueryId;
|
|
1731
|
+
} else if (deferCurrentTurn()) {
|
|
1732
|
+
waitToDrainRemoteInject(topicId);
|
|
1733
|
+
}
|
|
1734
|
+
return null;
|
|
1735
|
+
}
|
|
1736
|
+
try {
|
|
1737
|
+
writeQueryState(userId, topic.title, prompt);
|
|
1738
|
+
} catch (err) {
|
|
1739
|
+
logger.warn({ err, topicId, userId }, "ai: failed to write active query state");
|
|
1740
|
+
}
|
|
1741
|
+
try {
|
|
1742
|
+
onDispatched?.(queryId);
|
|
1743
|
+
} catch (err) {
|
|
1744
|
+
// Module/adapter observers are outside the turn's trust boundary. A broken
|
|
1745
|
+
// observer must not leave the room occupied without starting its provider.
|
|
1746
|
+
logger.warn({ err, topicId, queryId }, "ai: dispatch hook failed");
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// Priority: config override > topic default. (Per-message slash overrides
|
|
1750
|
+
// were removed — switching is config-only now.) Cross-agent-stale models are
|
|
1751
|
+
// dropped to the agent's default by resolveModelForAgent.
|
|
1752
|
+
const override = getTopicConfig(topicId);
|
|
1753
|
+
const agentKind = execution.agent;
|
|
1754
|
+
const resolvedModel = execution.model;
|
|
1755
|
+
const resolvedEffort = execution.effort;
|
|
1756
|
+
|
|
1757
|
+
const workspaceCwd = cwd ?? workspaceCwdFor(topicId);
|
|
1758
|
+
mkdirSync(workspaceCwd, { recursive: true });
|
|
1759
|
+
if (bridgeSessionFromHistory && !sessionId) {
|
|
1760
|
+
const bridgedSessionId = tryReconstructTopicRollout({
|
|
1761
|
+
topicId,
|
|
1762
|
+
topicTitle: sessionName,
|
|
1763
|
+
userId,
|
|
1764
|
+
agent: agentKind,
|
|
1765
|
+
sessionId: null,
|
|
1766
|
+
cwd: workspaceCwd,
|
|
1767
|
+
model: resolvedModel,
|
|
1768
|
+
...(resolvedEffort ? { effort: resolvedEffort } : {}),
|
|
1769
|
+
allowFreshSession: true,
|
|
1770
|
+
});
|
|
1771
|
+
if (bridgedSessionId) {
|
|
1772
|
+
sessionId = bridgedSessionId;
|
|
1773
|
+
control.sessionId = bridgedSessionId;
|
|
1774
|
+
if (control.injectParams) control.injectParams.sessionId = bridgedSessionId;
|
|
1775
|
+
try {
|
|
1776
|
+
onSessionId?.(bridgedSessionId);
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
logger.warn(
|
|
1779
|
+
{ err, topicId, queryId, agent: agentKind },
|
|
1780
|
+
"ai: isolated session owner rejected bridged provider session id",
|
|
1781
|
+
);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
const promptAttachments = materializePromptAttachments(topicId, queryId, attachments);
|
|
1786
|
+
const promptWithFiles = promptWithAttachments(prompt, promptAttachments);
|
|
1787
|
+
const agentPrompt =
|
|
1788
|
+
topic.aiMode === "mention" && isUserOrigin(origin) && !silent
|
|
1789
|
+
? buildMentionOnlyChannelPrompt({
|
|
1790
|
+
topicId,
|
|
1791
|
+
userId,
|
|
1792
|
+
prompt,
|
|
1793
|
+
promptWithFiles,
|
|
1794
|
+
hasSession: Boolean(sessionId),
|
|
1795
|
+
})
|
|
1796
|
+
: promptWithFiles;
|
|
1797
|
+
|
|
1798
|
+
logger.info(
|
|
1799
|
+
{
|
|
1800
|
+
queryId,
|
|
1801
|
+
topicId,
|
|
1802
|
+
resolvedAgent: agentKind,
|
|
1803
|
+
resolvedModel,
|
|
1804
|
+
resolvedEffort,
|
|
1805
|
+
origin,
|
|
1806
|
+
silent: Boolean(silent),
|
|
1807
|
+
requestId,
|
|
1808
|
+
depth,
|
|
1809
|
+
resumeSessionId: sessionId ? sessionId.slice(0, 8) : null,
|
|
1810
|
+
freshSession: !sessionId,
|
|
1811
|
+
autoContinue: allowAutoContinue,
|
|
1812
|
+
attachmentCount: attachments?.length ?? 0,
|
|
1813
|
+
materializedAttachmentCount: promptAttachments.length,
|
|
1814
|
+
},
|
|
1815
|
+
"ai: dispatching query",
|
|
1816
|
+
);
|
|
1817
|
+
|
|
1818
|
+
const aiLabel = getGlobalAiName();
|
|
1819
|
+
|
|
1820
|
+
// R2: inject shared topic brief into system prompt for AI-invited topic turns.
|
|
1821
|
+
// In-memory process can read SQLite synchronously — zero latency for hot path.
|
|
1822
|
+
const topicRecord = getTopic(topicId);
|
|
1823
|
+
const systemPromptOpts = {
|
|
1824
|
+
aiLabel,
|
|
1825
|
+
topicTitle: topic.title,
|
|
1826
|
+
workspaceCwd,
|
|
1827
|
+
agentKind,
|
|
1828
|
+
description: topic.description,
|
|
1829
|
+
canSpawnSubagents:
|
|
1830
|
+
peerBridge?.canSpawnSubagents ?? (topicRecord?.kind === "agent" && !topicRecord.isSubagent),
|
|
1831
|
+
};
|
|
1832
|
+
const isManager = topicRecord?.kind === "manager";
|
|
1833
|
+
const isMentionOnlyChannel = topic.aiMode === "mention" && !isManager;
|
|
1834
|
+
let systemPrompt = isManager
|
|
1835
|
+
? buildManagerSystemPrompt(systemPromptOpts)
|
|
1836
|
+
: isMentionOnlyChannel
|
|
1837
|
+
? buildChannelSystemPrompt(systemPromptOpts)
|
|
1838
|
+
: buildTopicSystemPrompt(systemPromptOpts);
|
|
1839
|
+
const memoryTopic =
|
|
1840
|
+
isMentionOnlyChannel || isManager ? topic : (getTopicMemoryOrigin(topicId) ?? topic);
|
|
1841
|
+
if (!isMentionOnlyChannel) {
|
|
1842
|
+
try {
|
|
1843
|
+
const resolvedBrief = resolveTopicBrief(memoryTopic.id, memoryTopic.title);
|
|
1844
|
+
if (resolvedBrief) {
|
|
1845
|
+
const { brief, storageKey } = resolvedBrief;
|
|
1846
|
+
// #General is the workspace memory hub: its brief is the rolling digest the
|
|
1847
|
+
// archiver accumulates across ALL archived topics, and its files live in the
|
|
1848
|
+
// SHARED wiki root (getSharedWikiDir), not this topic's per-room workspace.
|
|
1849
|
+
const wikiDir = getSharedWikiDir();
|
|
1850
|
+
systemPrompt += buildMemoryPromptSection({
|
|
1851
|
+
topicId: storageKey,
|
|
1852
|
+
wikiDir,
|
|
1853
|
+
hasFiles: true,
|
|
1854
|
+
latestSummaryFile: brief.summaryDate
|
|
1855
|
+
? `${wikiDir}/summaries/${wikiSummaryFilename(
|
|
1856
|
+
brief.summaryDate,
|
|
1857
|
+
memoryTopic.title,
|
|
1858
|
+
storageKey,
|
|
1859
|
+
)}`
|
|
1860
|
+
: null,
|
|
1861
|
+
hasArchive: Boolean(brief.latestSummaryMd),
|
|
1862
|
+
isManager,
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
} catch (err) {
|
|
1866
|
+
// Brief fetch failure is non-fatal — don't block the turn.
|
|
1867
|
+
logger.warn({ topicId, err }, "ai: failed to inject topic brief");
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
const activeVisual = getActiveVisualForPrompt(topicId, userId);
|
|
1871
|
+
if (activeVisual) {
|
|
1872
|
+
const label = activeVisual.title ? `"${activeVisual.title}"` : `#${activeVisual.index}`;
|
|
1873
|
+
const promptVisual = activeVisualHtmlForPrompt(activeVisual.content);
|
|
1874
|
+
const visualContentLabel =
|
|
1875
|
+
activeVisual.fence === "html"
|
|
1876
|
+
? "HTML"
|
|
1877
|
+
: activeVisual.fence === "mermaid"
|
|
1878
|
+
? "Mermaid source"
|
|
1879
|
+
: "visual content";
|
|
1880
|
+
systemPrompt += [
|
|
1881
|
+
"",
|
|
1882
|
+
"",
|
|
1883
|
+
"## Active Visual Panel",
|
|
1884
|
+
`The user is currently viewing a ${activeVisual.kind} visual ${label} (${activeVisual.index} of ${activeVisual.total}) in the side panel.`,
|
|
1885
|
+
"Its current source/content is shown below when available. Reference or modify it directly when the user asks to update the visual:",
|
|
1886
|
+
...(promptVisual.omittedChars > 0
|
|
1887
|
+
? [
|
|
1888
|
+
`The ${visualContentLabel} is truncated for prompt size; ${promptVisual.omittedChars} characters from the middle are omitted.`,
|
|
1889
|
+
]
|
|
1890
|
+
: []),
|
|
1891
|
+
`\`\`\`${activeVisual.fence}`,
|
|
1892
|
+
promptVisual.html,
|
|
1893
|
+
"```",
|
|
1894
|
+
].join("\n");
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
if (params.from === FROM_AUTO_CONTINUE) {
|
|
1898
|
+
systemPrompt +=
|
|
1899
|
+
"\n\n<system-reminder>이 턴은 설정 자동 조정(effort/model/agent) 후 자동 재개된 턴이다. effort/model/agent 난이도 재평가 및 설정 변경 없이 즉시 작업을 시작할 것.</system-reminder>";
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
if (!silent && !sessionRetried) {
|
|
1903
|
+
appendConversationEvent(userId, sessionName, agentKind, {
|
|
1904
|
+
type: "user_message",
|
|
1905
|
+
content: agentPrompt,
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// Wrap runAgent in a lazy async generator so we can `await ensurePlaywright`
|
|
1910
|
+
// at the moment streaming starts (startAiTurn itself is sync — it returns the
|
|
1911
|
+
// queryId immediately and streams in the background). Bring up a long-lived
|
|
1912
|
+
// Playwright MCP for this topic when (and only when) playwright is in the
|
|
1913
|
+
// topic's enabled set: without a live `playwrightPort`, mcp-config omits the
|
|
1914
|
+
// playwright entry entirely, so the agent connects but sees NO browser tools
|
|
1915
|
+
// — the "playwright active but tools missing" bug. The host must allocate
|
|
1916
|
+
// the port before building MCP config. Gated on the opt-in whitelist
|
|
1917
|
+
// so topics that don't use playwright never spawn a Chromium. Non-fatal.
|
|
1918
|
+
const enabledMcp = override?.mcp ?? [];
|
|
1919
|
+
const playwrightRequested = !isManager && enabledMcp.includes("playwright");
|
|
1920
|
+
const browserProfileOwner = isTopicBrowserProfileOwner(topic.id, userId);
|
|
1921
|
+
const wantsPlaywright = playwrightRequested && browserProfileOwner;
|
|
1922
|
+
const turnMcpEnabled = browserProfileOwner
|
|
1923
|
+
? enabledMcp
|
|
1924
|
+
: enabledMcp.filter((name) => name !== "playwright");
|
|
1925
|
+
const wantsBgBash = !isManager && enabledMcp.includes("background-bash");
|
|
1926
|
+
async function* runWithPlaywright(): AsyncGenerator<UnifiedEvent> {
|
|
1927
|
+
if (prepareSession && !sessionId) {
|
|
1928
|
+
if (abortController.signal.aborted) return;
|
|
1929
|
+
try {
|
|
1930
|
+
const prepared = await prepareSession();
|
|
1931
|
+
sessionId = prepared.forkId;
|
|
1932
|
+
forkHandle = prepared;
|
|
1933
|
+
control.sessionId = prepared.forkId;
|
|
1934
|
+
if (control.injectParams) {
|
|
1935
|
+
control.injectParams.sessionId = prepared.forkId;
|
|
1936
|
+
control.injectParams.forkHandle = prepared;
|
|
1937
|
+
}
|
|
1938
|
+
} catch (err) {
|
|
1939
|
+
throw new Error(`failed to prepare isolated session: ${stringifyError(err)}`);
|
|
1940
|
+
}
|
|
1941
|
+
if (abortController.signal.aborted) return;
|
|
1942
|
+
}
|
|
1943
|
+
let playwrightPort: number | undefined;
|
|
1944
|
+
let playwrightCapability: string | undefined;
|
|
1945
|
+
let playwrightInstanceKey: string | undefined;
|
|
1946
|
+
let bgBashPort: number | undefined;
|
|
1947
|
+
if (wantsPlaywright) {
|
|
1948
|
+
try {
|
|
1949
|
+
playwrightInstanceKey = makeInstanceKey(userId, topic.id);
|
|
1950
|
+
// Pin before ensurePlaywright's asynchronous health check so an idle
|
|
1951
|
+
// sweep cannot evict the instance between validation and turn setup.
|
|
1952
|
+
pinPlaywrightInstance(playwrightInstanceKey);
|
|
1953
|
+
playwrightPort = await ensurePlaywright(userId, topic.id);
|
|
1954
|
+
playwrightCapability = getPlaywrightCapability(playwrightInstanceKey);
|
|
1955
|
+
if (!playwrightCapability) {
|
|
1956
|
+
throw new Error("browser MCP capability was not registered");
|
|
1957
|
+
}
|
|
1958
|
+
} catch (err) {
|
|
1959
|
+
logger.warn(
|
|
1960
|
+
{ topicId, err },
|
|
1961
|
+
"ai: ensurePlaywright failed — proceeding without browser tools",
|
|
1962
|
+
);
|
|
1963
|
+
markPlaywrightUnavailable({
|
|
1964
|
+
userId,
|
|
1965
|
+
topic: topic.title,
|
|
1966
|
+
agent: agentKind,
|
|
1967
|
+
});
|
|
1968
|
+
if (!silent) notifyPlaywrightUnavailable(topicId);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
if (wantsBgBash) {
|
|
1972
|
+
try {
|
|
1973
|
+
bgBashPort = await ensureBgBash(userId, topic.id);
|
|
1974
|
+
} catch (err) {
|
|
1975
|
+
logger.warn(
|
|
1976
|
+
{ topicId, err },
|
|
1977
|
+
"ai: ensureBgBash failed — proceeding without background bash tools",
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
let effectiveSystemPrompt = systemPrompt;
|
|
1982
|
+
if (playwrightRequested && !browserProfileOwner) {
|
|
1983
|
+
const ownerOnlyNote =
|
|
1984
|
+
"<system-reminder>Browser tools are unavailable in this turn because browser profiles are private to the topic owner. Do not attempt browser calls or ask to reuse the owner's login state.</system-reminder>";
|
|
1985
|
+
effectiveSystemPrompt = `${effectiveSystemPrompt}\n\n${ownerOnlyNote}`;
|
|
1986
|
+
}
|
|
1987
|
+
if (consumePlaywrightUnavailable(userId, topic.title)) {
|
|
1988
|
+
const playwrightNote =
|
|
1989
|
+
"<system-reminder>Playwright browser tools are UNAVAILABLE this turn. The `mcp__playwright__*` tools have been removed from this turn's catalog because the long-lived browser MCP could not be prepared. Do not attempt to call browser tools. If browser interaction is required, ask the user to retry shortly or use a non-browser alternative.</system-reminder>";
|
|
1990
|
+
effectiveSystemPrompt = `${effectiveSystemPrompt}\n\n${playwrightNote}`;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
try {
|
|
1994
|
+
yield* runAgent({
|
|
1995
|
+
agent: agentKind,
|
|
1996
|
+
prompt: agentPrompt,
|
|
1997
|
+
attachments: promptAttachments,
|
|
1998
|
+
cwd: workspaceCwd,
|
|
1999
|
+
systemPrompt: effectiveSystemPrompt,
|
|
2000
|
+
sessionId,
|
|
2001
|
+
userId,
|
|
2002
|
+
session: sessionName,
|
|
2003
|
+
sessionType: sessionType ?? (isManager ? "manager" : "forum"),
|
|
2004
|
+
abortController,
|
|
2005
|
+
model: resolvedModel,
|
|
2006
|
+
effort: resolvedEffort,
|
|
2007
|
+
depth,
|
|
2008
|
+
silent,
|
|
2009
|
+
// Default OFF: with no override a topic loads only the REQUIRED servers (no
|
|
2010
|
+
// optional ones). Optional servers are opt-in per topic via the settings UI
|
|
2011
|
+
// / #General set_topic_mcp. `[]` = required-only; getForumMcpServers reserves
|
|
2012
|
+
// `null`/`undefined` for "all optional", which Otium no longer defaults to.
|
|
2013
|
+
mcpEnabled: turnMcpEnabled,
|
|
2014
|
+
playwrightPort,
|
|
2015
|
+
playwrightCapability,
|
|
2016
|
+
bgBashPort,
|
|
2017
|
+
topicId,
|
|
2018
|
+
queryId,
|
|
2019
|
+
wikiTopicId: memoryTopic.id,
|
|
2020
|
+
autoContinue: allowAutoContinue && !silent,
|
|
2021
|
+
peerBridge,
|
|
2022
|
+
});
|
|
2023
|
+
} finally {
|
|
2024
|
+
if (playwrightInstanceKey) unpinPlaywrightInstance(playwrightInstanceKey);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
// Provider streams can stay silent while reasoning, starting MCPs, or
|
|
2028
|
+
// waiting on a long tool. Otium covers Claude thinking signals; this
|
|
2029
|
+
// channel-neutral fallback keeps every Negotium host alive for all agents.
|
|
2030
|
+
const providerEvents = runWithPlaywright();
|
|
2031
|
+
const events = silent ? providerEvents : withTurnSilenceHeartbeat(providerEvents);
|
|
2032
|
+
|
|
2033
|
+
// Let UI show typing indicator while AI works (including tool calls / thinking).
|
|
2034
|
+
if (!silent) {
|
|
2035
|
+
WsHub.get().broadcastTyping(topicId, "ai");
|
|
2036
|
+
// Carry the queryId so clients can bind typing/tool UI to turns they did
|
|
2037
|
+
// not initiate (subagent task turns, session injects, other participants).
|
|
2038
|
+
WsHub.get().broadcastAiActive(topicId, queryId);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
streamAgentEvents(
|
|
2042
|
+
topicId,
|
|
2043
|
+
topic.title,
|
|
2044
|
+
queryId,
|
|
2045
|
+
events,
|
|
2046
|
+
control,
|
|
2047
|
+
agentKind,
|
|
2048
|
+
resolvedModel,
|
|
2049
|
+
resolvedEffort,
|
|
2050
|
+
userId,
|
|
2051
|
+
!sessionRetried,
|
|
2052
|
+
onSessionId,
|
|
2053
|
+
{ silent, peerBridge },
|
|
2054
|
+
)
|
|
2055
|
+
.then(async (outcome) => {
|
|
2056
|
+
if (outcome.kind === "session-expired") {
|
|
2057
|
+
if (!silent) WsHub.get().broadcastAborted(topicId, queryId, "stopped");
|
|
2058
|
+
const retrySessionId = resolveSessionRetryId({
|
|
2059
|
+
topicId,
|
|
2060
|
+
topicTitle: sessionName,
|
|
2061
|
+
userId,
|
|
2062
|
+
agent: agentKind,
|
|
2063
|
+
sessionId,
|
|
2064
|
+
cwd: workspaceCwd,
|
|
2065
|
+
silent: Boolean(silent),
|
|
2066
|
+
model: resolvedModel,
|
|
2067
|
+
...(resolvedEffort ? { effort: resolvedEffort } : {}),
|
|
2068
|
+
externalSessionOwner: Boolean(onSessionId),
|
|
2069
|
+
onSessionReset,
|
|
2070
|
+
});
|
|
2071
|
+
logger.info(
|
|
2072
|
+
{
|
|
2073
|
+
topicId,
|
|
2074
|
+
prevQueryId: queryId,
|
|
2075
|
+
agent: agentKind,
|
|
2076
|
+
retrySessionId: retrySessionId ? retrySessionId.slice(0, 8) : null,
|
|
2077
|
+
},
|
|
2078
|
+
"ai: retrying query after session expiry",
|
|
2079
|
+
);
|
|
2080
|
+
if (!retrySessionId && prepareSession && forkHandle) {
|
|
2081
|
+
// The retry will invoke the recipe and mint a different rollout.
|
|
2082
|
+
// The expired fork is no longer in use after stream cleanup.
|
|
2083
|
+
cleanupAgentFork(forkHandle);
|
|
2084
|
+
forkHandle = undefined;
|
|
2085
|
+
}
|
|
2086
|
+
startAiTurn({
|
|
2087
|
+
topic,
|
|
2088
|
+
userId,
|
|
2089
|
+
prompt,
|
|
2090
|
+
attachments,
|
|
2091
|
+
allowAutoContinue,
|
|
2092
|
+
origin,
|
|
2093
|
+
onDispatched,
|
|
2094
|
+
requestId,
|
|
2095
|
+
depth,
|
|
2096
|
+
silent,
|
|
2097
|
+
contextId,
|
|
2098
|
+
agentOverride,
|
|
2099
|
+
modelOverride,
|
|
2100
|
+
effortOverride,
|
|
2101
|
+
sessionId: retrySessionId,
|
|
2102
|
+
sessionScope,
|
|
2103
|
+
forkHandle,
|
|
2104
|
+
// Keep the recipe so a later user preemption can replay from a
|
|
2105
|
+
// clean snapshot. A non-empty retrySessionId suppresses immediate
|
|
2106
|
+
// preparation in runWithPlaywright.
|
|
2107
|
+
prepareSession,
|
|
2108
|
+
cwd,
|
|
2109
|
+
sessionName,
|
|
2110
|
+
sessionType,
|
|
2111
|
+
onSessionId,
|
|
2112
|
+
onSessionReset,
|
|
2113
|
+
bridgeSessionFromHistory,
|
|
2114
|
+
onSettled,
|
|
2115
|
+
peerBridge,
|
|
2116
|
+
askReplySources,
|
|
2117
|
+
_runtimeEpoch: runtimeEpoch,
|
|
2118
|
+
_sessionRetried: true,
|
|
2119
|
+
});
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
if (outcome.kind === "provider-error") {
|
|
2124
|
+
if (!silent) {
|
|
2125
|
+
// Provider failures are user-visible, but Otium does not automatically
|
|
2126
|
+
// switch the topic to another provider. Users may register multiple
|
|
2127
|
+
// providers and explicitly change this topic with /model.
|
|
2128
|
+
appendSystemMessage(
|
|
2129
|
+
topicId,
|
|
2130
|
+
`${classifyAgentError(outcome.error, agentKind)}\n\n다른 등록된 모델을 쓰려면 /model <model>로 바꾼 뒤 다시 보내세요.`,
|
|
2131
|
+
);
|
|
2132
|
+
WsHub.get().broadcastDone(topicId, queryId, undefined, {
|
|
2133
|
+
agent: agentKind,
|
|
2134
|
+
model: resolvedModel,
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
await deliverAskError(queryId, topic.title, outcome.error);
|
|
2138
|
+
await settleSubagentFailure(queryId, classifyAgentError(outcome.error, agentKind));
|
|
2139
|
+
try {
|
|
2140
|
+
onSettled?.({ queryId, kind: "error", error: outcome.error });
|
|
2141
|
+
} catch (err) {
|
|
2142
|
+
logger.warn({ err, topicId, queryId }, "ai: turn settlement hook failed");
|
|
2143
|
+
}
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
const requeuedAfterUserPreemption = wasLocallyRequeuedAfterUserPreemption(
|
|
2147
|
+
outcome.kind,
|
|
2148
|
+
control,
|
|
2149
|
+
);
|
|
2150
|
+
if (requeuedAfterUserPreemption) return;
|
|
2151
|
+
try {
|
|
2152
|
+
onSettled?.({
|
|
2153
|
+
queryId,
|
|
2154
|
+
kind: outcome.kind === "aborted" ? "aborted" : "completed",
|
|
2155
|
+
...(outcome.kind === "aborted" ? { abortReason: control.abortReason } : {}),
|
|
2156
|
+
});
|
|
2157
|
+
} catch (err) {
|
|
2158
|
+
logger.warn({ err, topicId, queryId }, "ai: turn settlement hook failed");
|
|
2159
|
+
}
|
|
2160
|
+
})
|
|
2161
|
+
.catch((err) => {
|
|
2162
|
+
logger.warn(
|
|
2163
|
+
{ err, topicId, queryId, agent: agentKind, model: resolvedModel, silent },
|
|
2164
|
+
"ai: background stream task failed",
|
|
2165
|
+
);
|
|
2166
|
+
if (!silent) {
|
|
2167
|
+
WsHub.get().broadcastError(
|
|
2168
|
+
topicId,
|
|
2169
|
+
queryId,
|
|
2170
|
+
err instanceof Error ? err.message : "Agent process crashed",
|
|
2171
|
+
);
|
|
2172
|
+
}
|
|
2173
|
+
try {
|
|
2174
|
+
onSettled?.({
|
|
2175
|
+
queryId,
|
|
2176
|
+
kind: "error",
|
|
2177
|
+
error: err instanceof Error ? err.message : "Agent process crashed",
|
|
2178
|
+
});
|
|
2179
|
+
} catch (hookErr) {
|
|
2180
|
+
logger.warn({ err: hookErr, topicId, queryId }, "ai: turn settlement hook failed");
|
|
2181
|
+
}
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
return queryId;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
export function wasLocallyRequeuedAfterUserPreemption(
|
|
2188
|
+
outcomeKind: StreamAgentOutcome["kind"],
|
|
2189
|
+
control: Pick<RoomQueryControl, "abortReason" | "injectParams" | "injectRequeued">,
|
|
2190
|
+
): boolean {
|
|
2191
|
+
return (
|
|
2192
|
+
outcomeKind === "aborted" &&
|
|
2193
|
+
control.abortReason === AbortReason.Internal &&
|
|
2194
|
+
Boolean(control.injectParams) &&
|
|
2195
|
+
control.injectRequeued === true
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
/**
|
|
2200
|
+
* Programmatic AI turn trigger — used by session-comm (tell/ask) and any
|
|
2201
|
+
* internal consumer that needs to inject a prompt into a topic's AI pipeline.
|
|
2202
|
+
*
|
|
2203
|
+
* Returns the queryId so callers can track the result, or null if the topic
|
|
2204
|
+
* has no AI participant (AI not invited).
|
|
2205
|
+
*/
|
|
2206
|
+
export function triggerTopicAiTurn(
|
|
2207
|
+
topicId: string,
|
|
2208
|
+
userId: string,
|
|
2209
|
+
prompt: string,
|
|
2210
|
+
agentType?: AgentKind,
|
|
2211
|
+
opts?: TriggerTopicAiTurnOptions,
|
|
2212
|
+
): string | null {
|
|
2213
|
+
const topics = getTopics();
|
|
2214
|
+
const topic = topics.find((t) => t.id === topicId) as TopicDto | undefined;
|
|
2215
|
+
if (!topic?.agent) return null;
|
|
2216
|
+
|
|
2217
|
+
const topicCfg: AiTurnTopic = {
|
|
2218
|
+
id: topic.id,
|
|
2219
|
+
title: topic.title,
|
|
2220
|
+
kind: topic.kind,
|
|
2221
|
+
description: topic.description,
|
|
2222
|
+
agent: topic.agent,
|
|
2223
|
+
defaultModel: topic.defaultModel,
|
|
2224
|
+
defaultEffort: topic.defaultEffort,
|
|
2225
|
+
aiMode: topic.aiMode,
|
|
2226
|
+
aiMention: topic.aiMention,
|
|
2227
|
+
};
|
|
2228
|
+
const execution = resolveTopicTurnExecution(topicCfg, {
|
|
2229
|
+
agentOverride: agentType,
|
|
2230
|
+
modelOverride: opts?.modelOverride,
|
|
2231
|
+
effortOverride: opts?.effortOverride,
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
if (!opts?.silent && !opts?.hideInjectMessage) {
|
|
2235
|
+
// Show the injected message in the receiving topic immediately, decoupled
|
|
2236
|
+
// from when the AI turn actually runs — the turn may be deferred behind a
|
|
2237
|
+
// running user turn, but participants should see the inject arrive now.
|
|
2238
|
+
const now = new Date().toISOString();
|
|
2239
|
+
const injectMsg: MessageDto = {
|
|
2240
|
+
id: `tell-${randomUUID()}`,
|
|
2241
|
+
topicId,
|
|
2242
|
+
authorId: opts?.injectAuthorId ?? userId,
|
|
2243
|
+
sourceAdapter: opts?.injectSourceAdapter,
|
|
2244
|
+
authorName: execution.agent,
|
|
2245
|
+
text: prompt,
|
|
2246
|
+
agentType: execution.agent,
|
|
2247
|
+
model: execution.model,
|
|
2248
|
+
createdAt: now,
|
|
2249
|
+
};
|
|
2250
|
+
appendApiMessage(injectMsg);
|
|
2251
|
+
WsHub.get().broadcastMessage(topicId, injectMsg);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// Dispatch (or defer) the AI turn. origin != "user" → never preempts a
|
|
2255
|
+
// running user turn; the user keeps priority (Otium inter-session rule).
|
|
2256
|
+
return startAiTurn({
|
|
2257
|
+
topic: topicCfg,
|
|
2258
|
+
userId,
|
|
2259
|
+
prompt,
|
|
2260
|
+
attachments: opts?.attachments,
|
|
2261
|
+
allowAutoContinue: false,
|
|
2262
|
+
origin: opts?.origin ?? `inject:${agentType ?? topic.agent ?? "session"}`,
|
|
2263
|
+
onDispatched: opts?.onDispatched,
|
|
2264
|
+
requestId: opts?.requestId,
|
|
2265
|
+
depth: opts?.depth,
|
|
2266
|
+
silent: opts?.silent,
|
|
2267
|
+
contextId: opts?.contextId,
|
|
2268
|
+
agentOverride: agentType,
|
|
2269
|
+
modelOverride: opts?.modelOverride,
|
|
2270
|
+
effortOverride: opts?.effortOverride,
|
|
2271
|
+
sessionId: opts?.sessionId,
|
|
2272
|
+
sessionScope: opts?.sessionScope,
|
|
2273
|
+
forkHandle: opts?.forkHandle,
|
|
2274
|
+
prepareSession: opts?.prepareSession,
|
|
2275
|
+
cwd: opts?.cwd,
|
|
2276
|
+
sessionName: opts?.sessionName,
|
|
2277
|
+
sessionType: opts?.sessionType,
|
|
2278
|
+
onSessionId: opts?.onSessionId,
|
|
2279
|
+
onSessionReset: opts?.onSessionReset,
|
|
2280
|
+
bridgeSessionFromHistory: opts?.bridgeSessionFromHistory,
|
|
2281
|
+
onSettled: opts?.onSettled,
|
|
2282
|
+
peerBridge: opts?.peerBridge,
|
|
2283
|
+
askReplySources: opts?.askReplySources,
|
|
2284
|
+
from: opts?.from,
|
|
2285
|
+
});
|
|
2286
|
+
}
|