@krischoichoi/channel-harness 0.6.0
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 +110 -0
- package/lib/access/controller.d.ts +37 -0
- package/lib/access/controller.d.ts.map +1 -0
- package/lib/access/controller.js +51 -0
- package/lib/access/controller.js.map +1 -0
- package/lib/access/decision.d.ts +18 -0
- package/lib/access/decision.d.ts.map +1 -0
- package/lib/access/decision.js +2 -0
- package/lib/access/decision.js.map +1 -0
- package/lib/access/resolver.d.ts +41 -0
- package/lib/access/resolver.d.ts.map +1 -0
- package/lib/access/resolver.js +31 -0
- package/lib/access/resolver.js.map +1 -0
- package/lib/agent-manager.d.ts +314 -0
- package/lib/agent-manager.d.ts.map +1 -0
- package/lib/agent-manager.js +603 -0
- package/lib/agent-manager.js.map +1 -0
- package/lib/agent-router.d.ts +37 -0
- package/lib/agent-router.d.ts.map +1 -0
- package/lib/agent-router.js +33 -0
- package/lib/agent-router.js.map +1 -0
- package/lib/bind-support.d.ts +23 -0
- package/lib/bind-support.d.ts.map +1 -0
- package/lib/bind-support.js +63 -0
- package/lib/bind-support.js.map +1 -0
- package/lib/binding-store.d.ts +104 -0
- package/lib/binding-store.d.ts.map +1 -0
- package/lib/binding-store.js +354 -0
- package/lib/binding-store.js.map +1 -0
- package/lib/bridge.d.ts +248 -0
- package/lib/bridge.d.ts.map +1 -0
- package/lib/bridge.js +961 -0
- package/lib/bridge.js.map +1 -0
- package/lib/channel-label.d.ts +46 -0
- package/lib/channel-label.d.ts.map +1 -0
- package/lib/channel-label.js +89 -0
- package/lib/channel-label.js.map +1 -0
- package/lib/channel-session-factory.d.ts +76 -0
- package/lib/channel-session-factory.d.ts.map +1 -0
- package/lib/channel-session-factory.js +187 -0
- package/lib/channel-session-factory.js.map +1 -0
- package/lib/commands/bind.d.ts +21 -0
- package/lib/commands/bind.d.ts.map +1 -0
- package/lib/commands/bind.js +91 -0
- package/lib/commands/bind.js.map +1 -0
- package/lib/commands/help.d.ts +15 -0
- package/lib/commands/help.d.ts.map +1 -0
- package/lib/commands/help.js +89 -0
- package/lib/commands/help.js.map +1 -0
- package/lib/commands/index.d.ts +122 -0
- package/lib/commands/index.d.ts.map +1 -0
- package/lib/commands/index.js +63 -0
- package/lib/commands/index.js.map +1 -0
- package/lib/commands/locale.d.ts +9 -0
- package/lib/commands/locale.d.ts.map +1 -0
- package/lib/commands/locale.js +27 -0
- package/lib/commands/locale.js.map +1 -0
- package/lib/commands/mirror.d.ts +14 -0
- package/lib/commands/mirror.d.ts.map +1 -0
- package/lib/commands/mirror.js +44 -0
- package/lib/commands/mirror.js.map +1 -0
- package/lib/commands/model.d.ts +20 -0
- package/lib/commands/model.d.ts.map +1 -0
- package/lib/commands/model.js +106 -0
- package/lib/commands/model.js.map +1 -0
- package/lib/commands/models.d.ts +14 -0
- package/lib/commands/models.d.ts.map +1 -0
- package/lib/commands/models.js +67 -0
- package/lib/commands/models.js.map +1 -0
- package/lib/commands/new.d.ts +14 -0
- package/lib/commands/new.d.ts.map +1 -0
- package/lib/commands/new.js +37 -0
- package/lib/commands/new.js.map +1 -0
- package/lib/commands/status.d.ts +12 -0
- package/lib/commands/status.d.ts.map +1 -0
- package/lib/commands/status.js +30 -0
- package/lib/commands/status.js.map +1 -0
- package/lib/commands/stop.d.ts +16 -0
- package/lib/commands/stop.d.ts.map +1 -0
- package/lib/commands/stop.js +27 -0
- package/lib/commands/stop.js.map +1 -0
- package/lib/commands/version.d.ts +25 -0
- package/lib/commands/version.d.ts.map +1 -0
- package/lib/commands/version.js +57 -0
- package/lib/commands/version.js.map +1 -0
- package/lib/config.d.ts +119 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +60 -0
- package/lib/config.js.map +1 -0
- package/lib/debug-logger.d.ts +8 -0
- package/lib/debug-logger.d.ts.map +1 -0
- package/lib/debug-logger.js +32 -0
- package/lib/debug-logger.js.map +1 -0
- package/lib/dsh-home.d.ts +12 -0
- package/lib/dsh-home.d.ts.map +1 -0
- package/lib/dsh-home.js +31 -0
- package/lib/dsh-home.js.map +1 -0
- package/lib/failure-display.d.ts +10 -0
- package/lib/failure-display.d.ts.map +1 -0
- package/lib/failure-display.js +42 -0
- package/lib/failure-display.js.map +1 -0
- package/lib/file-provider.d.ts +96 -0
- package/lib/file-provider.d.ts.map +1 -0
- package/lib/file-provider.js +44 -0
- package/lib/file-provider.js.map +1 -0
- package/lib/index.d.ts +31 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +31 -0
- package/lib/index.js.map +1 -0
- package/lib/interactions/index.d.ts +13 -0
- package/lib/interactions/index.d.ts.map +1 -0
- package/lib/interactions/index.js +13 -0
- package/lib/interactions/index.js.map +1 -0
- package/lib/interactions/question-backend.d.ts +133 -0
- package/lib/interactions/question-backend.d.ts.map +1 -0
- package/lib/interactions/question-backend.js +37 -0
- package/lib/interactions/question-backend.js.map +1 -0
- package/lib/interactions/question-presenter.d.ts +68 -0
- package/lib/interactions/question-presenter.d.ts.map +1 -0
- package/lib/interactions/question-presenter.js +425 -0
- package/lib/interactions/question-presenter.js.map +1 -0
- package/lib/interactions/question-renderer.d.ts +37 -0
- package/lib/interactions/question-renderer.d.ts.map +1 -0
- package/lib/interactions/question-renderer.js +79 -0
- package/lib/interactions/question-renderer.js.map +1 -0
- package/lib/interactions/question-state.d.ts +106 -0
- package/lib/interactions/question-state.d.ts.map +1 -0
- package/lib/interactions/question-state.js +99 -0
- package/lib/interactions/question-state.js.map +1 -0
- package/lib/interactions/question-text-answer.d.ts +37 -0
- package/lib/interactions/question-text-answer.d.ts.map +1 -0
- package/lib/interactions/question-text-answer.js +70 -0
- package/lib/interactions/question-text-answer.js.map +1 -0
- package/lib/interactions/question-waterfall-backend.d.ts +42 -0
- package/lib/interactions/question-waterfall-backend.d.ts.map +1 -0
- package/lib/interactions/question-waterfall-backend.js +162 -0
- package/lib/interactions/question-waterfall-backend.js.map +1 -0
- package/lib/lifecycle.d.ts +36 -0
- package/lib/lifecycle.d.ts.map +1 -0
- package/lib/lifecycle.js +303 -0
- package/lib/lifecycle.js.map +1 -0
- package/lib/loggable-error.d.ts +10 -0
- package/lib/loggable-error.d.ts.map +1 -0
- package/lib/loggable-error.js +32 -0
- package/lib/loggable-error.js.map +1 -0
- package/lib/message-converter.d.ts +64 -0
- package/lib/message-converter.d.ts.map +1 -0
- package/lib/message-converter.js +191 -0
- package/lib/message-converter.js.map +1 -0
- package/lib/model-selection.d.ts +54 -0
- package/lib/model-selection.d.ts.map +1 -0
- package/lib/model-selection.js +116 -0
- package/lib/model-selection.js.map +1 -0
- package/lib/outbox/binding-resolver.d.ts +23 -0
- package/lib/outbox/binding-resolver.d.ts.map +1 -0
- package/lib/outbox/binding-resolver.js +38 -0
- package/lib/outbox/binding-resolver.js.map +1 -0
- package/lib/outbox/capabilities.d.ts +48 -0
- package/lib/outbox/capabilities.d.ts.map +1 -0
- package/lib/outbox/capabilities.js +35 -0
- package/lib/outbox/capabilities.js.map +1 -0
- package/lib/outbox/index.d.ts +14 -0
- package/lib/outbox/index.d.ts.map +1 -0
- package/lib/outbox/index.js +14 -0
- package/lib/outbox/index.js.map +1 -0
- package/lib/outbox/service.d.ts +48 -0
- package/lib/outbox/service.d.ts.map +1 -0
- package/lib/outbox/service.js +74 -0
- package/lib/outbox/service.js.map +1 -0
- package/lib/outbox/target.d.ts +17 -0
- package/lib/outbox/target.d.ts.map +1 -0
- package/lib/outbox/target.js +15 -0
- package/lib/outbox/target.js.map +1 -0
- package/lib/outbox/tool-send.d.ts +19 -0
- package/lib/outbox/tool-send.d.ts.map +1 -0
- package/lib/outbox/tool-send.js +77 -0
- package/lib/outbox/tool-send.js.map +1 -0
- package/lib/outbox/types.d.ts +42 -0
- package/lib/outbox/types.d.ts.map +1 -0
- package/lib/outbox/types.js +30 -0
- package/lib/outbox/types.js.map +1 -0
- package/lib/plugin.d.ts +27 -0
- package/lib/plugin.d.ts.map +1 -0
- package/lib/plugin.js +47 -0
- package/lib/plugin.js.map +1 -0
- package/lib/reply-context-store.d.ts +69 -0
- package/lib/reply-context-store.d.ts.map +1 -0
- package/lib/reply-context-store.js +75 -0
- package/lib/reply-context-store.js.map +1 -0
- package/lib/reply-router.d.ts +138 -0
- package/lib/reply-router.d.ts.map +1 -0
- package/lib/reply-router.js +674 -0
- package/lib/reply-router.js.map +1 -0
- package/lib/session-router.d.ts +49 -0
- package/lib/session-router.d.ts.map +1 -0
- package/lib/session-router.js +45 -0
- package/lib/session-router.js.map +1 -0
- package/lib/workspace-resolver.d.ts +67 -0
- package/lib/workspace-resolver.d.ts.map +1 -0
- package/lib/workspace-resolver.js +102 -0
- package/lib/workspace-resolver.js.map +1 -0
- package/package.json +103 -0
package/lib/bridge.js
ADDED
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChannelHarnessBridge — the inbound half: `ChannelEvent` -> session binding
|
|
3
|
+
* -> agent resolution -> command plane / `agent.followup` (doc H0.3–H0.7,
|
|
4
|
+
* command plane spec §2–§12, §36).
|
|
5
|
+
*
|
|
6
|
+
* Only `message.received` is handled in v1; every other event type is logged
|
|
7
|
+
* at debug level. Conversations are isolated by their canonical key
|
|
8
|
+
* (channel:account:conversation[:thread]), never by account alone.
|
|
9
|
+
*
|
|
10
|
+
* Two input planes: a **human command plane** (official
|
|
11
|
+
* `@deepseek-ai/dsh-commands` `parseCommand`/`commands.execute`) and a
|
|
12
|
+
* **model message plane** (`agent.followup`). A syntactically valid command
|
|
13
|
+
* is resolved through the official registry and its `CommandResult` is
|
|
14
|
+
* rendered directly to the channel — it is never sent to the model and never
|
|
15
|
+
* creates `assistant/message` (`ReplyRouter` is bypassed). An UNREGISTERED
|
|
16
|
+
* slash command follows official Host semantics: it is rejected with a
|
|
17
|
+
* direct channel notice and never enters the Agent prompt — `commands.execute`
|
|
18
|
+
* returns `undefined` for admission misses, which (given the syntax already
|
|
19
|
+
* parsed) means `ctx.commands.find(agent, name)` missed.
|
|
20
|
+
*
|
|
21
|
+
* Per-conversation serialization: all `message.received` handling for one
|
|
22
|
+
* canonical key runs through a lightweight per-key promise chain so a `/new`
|
|
23
|
+
* fully completes (Binding → B) before the next message on the SAME
|
|
24
|
+
* conversation starts, while different conversations run in parallel. Errors
|
|
25
|
+
* are caught + logged and never poison the chain.
|
|
26
|
+
*
|
|
27
|
+
* `/stop` is the one scheduling exception (spec §4–§10): its COMMAND
|
|
28
|
+
* semantics belong to the registry (see commands/stop.ts), but its SCHEDULING
|
|
29
|
+
* is a FAST PATH executed outside the serial chain — it bumps the
|
|
30
|
+
* per-conversation generation first (invalidating every stale queued message),
|
|
31
|
+
* cancels the live agent, acknowledges immediately (never waiting for
|
|
32
|
+
* `whenIdle`), and enqueues an internal stop barrier that re-cancels the
|
|
33
|
+
* LATEST binding's agent after prior chain work converges (covering the /new
|
|
34
|
+
* race).
|
|
35
|
+
*/
|
|
36
|
+
import { randomUUID } from 'node:crypto';
|
|
37
|
+
import {} from '@deepseek-ai/cordis';
|
|
38
|
+
import { parseCommand } from '@deepseek-ai/dsh-commands';
|
|
39
|
+
import { PersistenceUnavailableError, SessionNotFoundError } from './agent-manager.js';
|
|
40
|
+
import { routesEqual } from './agent-router.js';
|
|
41
|
+
import { sessionKey, } from './session-router.js';
|
|
42
|
+
import { toHarnessUserMessage, } from './message-converter.js';
|
|
43
|
+
import { installAttachmentCompatibilityTools, } from './file-provider.js';
|
|
44
|
+
import { ReplyContextStore } from './reply-context-store.js';
|
|
45
|
+
import { installSendChannelMessageTool } from './outbox/tool-send.js';
|
|
46
|
+
import { installChannelCommands, } from './commands/index.js';
|
|
47
|
+
import { ChannelModelSelectionController } from './model-selection.js';
|
|
48
|
+
import { toLoggableError } from './loggable-error.js';
|
|
49
|
+
import { ChannelSessionFactory } from './channel-session-factory.js';
|
|
50
|
+
import { isReservedClaimCommand } from '@krischoichoi/channel-core';
|
|
51
|
+
import { InboundAccessController } from './access/controller.js';
|
|
52
|
+
import { commandLocaleFromSettings } from './commands/locale.js';
|
|
53
|
+
function trimBrandedId(value) {
|
|
54
|
+
return value.trim();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Error historically raised when a Workspace attach failed inside fresh Session
|
|
58
|
+
* creation. Workspace attach is now non-fatal: the freshly-created session is
|
|
59
|
+
* kept (grouped as ungrouped) and the binding + followup continue, so the
|
|
60
|
+
* bridge no longer produces this error.
|
|
61
|
+
*
|
|
62
|
+
* @deprecated Workspace attachment failures are non-fatal and no longer raise
|
|
63
|
+
* this error. Retained as a public export for compatibility with existing
|
|
64
|
+
* imports; do not add new uses.
|
|
65
|
+
*/
|
|
66
|
+
export class ChannelWorkspaceAttachError extends Error {
|
|
67
|
+
sessionId;
|
|
68
|
+
workspaceId;
|
|
69
|
+
cwd;
|
|
70
|
+
channelId;
|
|
71
|
+
accountId;
|
|
72
|
+
constructor(input) {
|
|
73
|
+
super(`channel session '${input.sessionId}' could not attach to workspace '${input.workspaceId}'`);
|
|
74
|
+
this.name = 'ChannelWorkspaceAttachError';
|
|
75
|
+
this.sessionId = input.sessionId;
|
|
76
|
+
this.workspaceId = input.workspaceId;
|
|
77
|
+
this.cwd = input.cwd;
|
|
78
|
+
this.channelId = input.channelId;
|
|
79
|
+
this.accountId = input.accountId;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export class ChannelHarnessBridge {
|
|
83
|
+
options;
|
|
84
|
+
sessionFactory;
|
|
85
|
+
commandDisposers = new Set();
|
|
86
|
+
modelSelectionDisposers = new Set();
|
|
87
|
+
commandSetupsDisposed = false;
|
|
88
|
+
/** Thin view over Harness's session/default model semantics. */
|
|
89
|
+
modelSelection;
|
|
90
|
+
/** Normalized command deps handed to every agent setup. */
|
|
91
|
+
commandDeps;
|
|
92
|
+
/** Per-conversation generation counters, invalidated by /stop (spec §6). */
|
|
93
|
+
conversationGenerations = new Map();
|
|
94
|
+
/** Pure fail-closed access decision engine. No I/O. */
|
|
95
|
+
accessController = new InboundAccessController();
|
|
96
|
+
constructor(options) {
|
|
97
|
+
this.options = options;
|
|
98
|
+
if (!options.accessResolver) {
|
|
99
|
+
throw new Error('channel-harness requires an access policy resolver');
|
|
100
|
+
}
|
|
101
|
+
this.modelSelection =
|
|
102
|
+
options.commandDeps.modelSelection ?? new ChannelModelSelectionController(options.ctx);
|
|
103
|
+
// Every Harness service reach is bridged LAZILY from the plugin context
|
|
104
|
+
// (options.ctx): command handlers must never read services through
|
|
105
|
+
// invocation.agent.ctx — the agent-loop scoped context does not inject
|
|
106
|
+
// commands/llm, and Cordis throws "without inject" there. This mirrors the
|
|
107
|
+
// /new pattern: narrow deps, bridge-owned implementations (official
|
|
108
|
+
// compact/goal/plan commands close over their plugin ctx the same way).
|
|
109
|
+
this.commandDeps = {
|
|
110
|
+
...options.commandDeps,
|
|
111
|
+
modelSelection: this.modelSelection,
|
|
112
|
+
listCommands: (agent) => this.options.ctx.commands.list(agent),
|
|
113
|
+
findCommand: (agent, name) => this.options.ctx.commands.find(agent, name),
|
|
114
|
+
locale: options.commandDeps.locale ?? (() => commandLocaleFromSettings(this.options.ctx.get('settings'))),
|
|
115
|
+
llm: {
|
|
116
|
+
listProviders: () => this.options.ctx.llm.listProviders(),
|
|
117
|
+
listModels: (provider) => this.options.ctx.llm.listModels(provider),
|
|
118
|
+
resolveModelInfo: (provider, model, signal) => this.options.ctx.llm.resolveModelInfo(provider, model, signal),
|
|
119
|
+
resolveCallConfig: (config, signal) => this.options.ctx.llm.resolveCallConfig(config, signal),
|
|
120
|
+
},
|
|
121
|
+
// /version's update hint: live probe of the (optional) control plane.
|
|
122
|
+
// `ctx.get` is the official detection API — safe on any scope, undefined
|
|
123
|
+
// when channel-control is not mounted (headless-without-control or the
|
|
124
|
+
// check disabled). The probe re-runs on every /version so an HMR reload
|
|
125
|
+
// of the control plugin is picked up without restarting the bridge.
|
|
126
|
+
versionInfo: async () => {
|
|
127
|
+
try {
|
|
128
|
+
const control = this.options.ctx.get('channelControl');
|
|
129
|
+
return await control?.getUpdateStatus();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
this.sessionFactory = new ChannelSessionFactory({
|
|
137
|
+
ctx: options.ctx,
|
|
138
|
+
cwd: options.config.cwd,
|
|
139
|
+
bindingStore: options.bindingStore,
|
|
140
|
+
agentManager: options.agentManager,
|
|
141
|
+
workspaceResolver: options.workspaceResolver,
|
|
142
|
+
commandSetup: this.commandSetup,
|
|
143
|
+
logger: options.logger,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** Per-conversation promise chains; entries self-clean on settle. */
|
|
147
|
+
chains = new Map();
|
|
148
|
+
/**
|
|
149
|
+
* Per-conversation serialization. Each canonical key has an owning promise
|
|
150
|
+
* chain; `fn` is appended onto the previous entry for that key so it starts
|
|
151
|
+
* only after the prior one settles, while distinct keys run in parallel. The
|
|
152
|
+
* returned promise resolves only after THIS operation has been handled; the
|
|
153
|
+
* chain entry absorbs errors (logged, never rethrown) so ONE failing message
|
|
154
|
+
* never poisons the conversation chain, while this call still surfaces THIS
|
|
155
|
+
* operation's error to its await-er (preserving prior rejection semantics).
|
|
156
|
+
*/
|
|
157
|
+
async enqueueSelf(key, fn) {
|
|
158
|
+
const prev = this.chains.get(key) ?? Promise.resolve();
|
|
159
|
+
const task = prev.then(fn);
|
|
160
|
+
const chain = task.catch((error) => {
|
|
161
|
+
this.options.logger.error(`[channel-harness] message handling failed for conversation '${key}'`, toLoggableError(error));
|
|
162
|
+
});
|
|
163
|
+
this.chains.set(key, chain);
|
|
164
|
+
void chain.finally(() => {
|
|
165
|
+
if (this.chains.get(key) === chain)
|
|
166
|
+
this.chains.delete(key);
|
|
167
|
+
});
|
|
168
|
+
await task;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Append work onto the per-conversation chain WITHOUT awaiting its
|
|
172
|
+
* completion (used by the /stop stop barrier — spec §9). The returned
|
|
173
|
+
* promise never rejects.
|
|
174
|
+
*/
|
|
175
|
+
enqueueConversation(key, fn) {
|
|
176
|
+
const prev = this.chains.get(key) ?? Promise.resolve();
|
|
177
|
+
const task = prev.then(fn);
|
|
178
|
+
const chain = task.catch((error) => {
|
|
179
|
+
this.options.logger.error(`[channel-harness] queued message handling failed for conversation '${key}'`, toLoggableError(error));
|
|
180
|
+
});
|
|
181
|
+
this.chains.set(key, chain);
|
|
182
|
+
void chain.finally(() => {
|
|
183
|
+
if (this.chains.get(key) === chain)
|
|
184
|
+
this.chains.delete(key);
|
|
185
|
+
});
|
|
186
|
+
return task.catch(() => undefined);
|
|
187
|
+
}
|
|
188
|
+
async handleChannelEvent(event) {
|
|
189
|
+
// Connection/auth state is consumed by the control plane and Web status
|
|
190
|
+
// surface. It is expected to be frequent during startup/reconnect and is
|
|
191
|
+
// not an inbound message for the Harness Agent bridge.
|
|
192
|
+
if (event.type === 'connection.changed' || event.type === 'auth.changed') {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (event.type === 'interaction.received') {
|
|
196
|
+
const normalized = this.normalizeInteractionIdentity(event);
|
|
197
|
+
if (await this.enforceInteractionAccessGate(normalized))
|
|
198
|
+
return;
|
|
199
|
+
if (await this.options.questionPresenter?.handleChannelEvent(normalized))
|
|
200
|
+
return;
|
|
201
|
+
this.options.logger.debug('[channel-harness] ignoring unmatched interaction.received');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (event.type !== 'message.received') {
|
|
205
|
+
this.options.logger.debug(`[channel-harness] ignoring channel event '${event.type}'`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// The command parser operates on the RAW user text (the concatenated plain
|
|
209
|
+
// text blocks), never on the '[channel=.. sender=.. message=..] ' metadata
|
|
210
|
+
// prefix the model-facing converter prepends, and never after a trim (the
|
|
211
|
+
// official parseCommand requires '/' at byte zero — spec §5).
|
|
212
|
+
const normalizedEvent = this.normalizeInboundIdentity(event);
|
|
213
|
+
const text = normalizedEvent.message.content
|
|
214
|
+
.filter((part) => part.type === 'text')
|
|
215
|
+
.map((part) => part.text)
|
|
216
|
+
.join('');
|
|
217
|
+
// ------------------------------------------------------------------
|
|
218
|
+
// FAIL-CLOSED ACCESS GATE. Runs BEFORE any side effect:
|
|
219
|
+
// before conversationKey / parseCommand / /stop / binding writes /
|
|
220
|
+
// session / workspace / agent. A drop here means NO side effect at all
|
|
221
|
+
// (incl. /stop fast path — an unauthorized user can never cancel a live
|
|
222
|
+
// agent or bump the generation).
|
|
223
|
+
// ------------------------------------------------------------------
|
|
224
|
+
const accessPolicy = await this.enforceAccessGate(normalizedEvent, text);
|
|
225
|
+
if (!accessPolicy)
|
|
226
|
+
return;
|
|
227
|
+
if (await this.options.questionPresenter?.handleChannelEvent(normalizedEvent))
|
|
228
|
+
return;
|
|
229
|
+
const parsed = parseCommand(text);
|
|
230
|
+
if (parsed &&
|
|
231
|
+
normalizedEvent.conversation.type === 'group' &&
|
|
232
|
+
normalizedEvent.sender.id !== accessPolicy.ownerId) {
|
|
233
|
+
const accessLogger = this.options.accessLogger ?? this.options.logger;
|
|
234
|
+
accessLogger.info('[channel-access] group command denied', {
|
|
235
|
+
channel: normalizedEvent.channel,
|
|
236
|
+
account: normalizedEvent.accountId,
|
|
237
|
+
conversationType: normalizedEvent.conversation.type,
|
|
238
|
+
reason: 'command_owner_required',
|
|
239
|
+
});
|
|
240
|
+
await this.sendCommandNotice(normalizedEvent, '群聊指令仅所有者可用。');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const key = this.conversationKey(normalizedEvent);
|
|
244
|
+
// P0: /stop is handled on a FAST PATH AND RUNS IMMEDIATELY — it must
|
|
245
|
+
// NEVER be chained behind queued conversation work (spec §4/§5), because
|
|
246
|
+
// the whole point is to interrupt an in-flight turn. `handleImmediateStop`
|
|
247
|
+
// bumps the generation synchronously before its first await, so every
|
|
248
|
+
// already-queued message (captured with the OLD generation) is invalidated
|
|
249
|
+
// at its next generation check and can never re-wake the agent.
|
|
250
|
+
if (parsed?.name === 'stop') {
|
|
251
|
+
try {
|
|
252
|
+
await this.handleImmediateStop(normalizedEvent, key, text);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
this.options.logger.error(`[channel-harness] /stop handling failed for conversation '${key}'`, toLoggableError(error));
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const generation = this.generationOf(key);
|
|
260
|
+
await this.enqueueSelf(key, () => this.handleQueuedMessage(normalizedEvent, key, text, parsed, generation));
|
|
261
|
+
}
|
|
262
|
+
/** Apply the contract's only identity normalization before any side effect. */
|
|
263
|
+
normalizeInboundIdentity(event) {
|
|
264
|
+
const senderId = trimBrandedId(event.sender.id);
|
|
265
|
+
const conversationId = trimBrandedId(event.conversation.id);
|
|
266
|
+
if (senderId === event.sender.id && conversationId === event.conversation.id) {
|
|
267
|
+
return event;
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
...event,
|
|
271
|
+
sender: { ...event.sender, id: senderId },
|
|
272
|
+
conversation: { ...event.conversation, id: conversationId },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
normalizeInteractionIdentity(event) {
|
|
276
|
+
return {
|
|
277
|
+
...event,
|
|
278
|
+
sender: { ...event.sender, id: trimBrandedId(event.sender.id) },
|
|
279
|
+
conversation: {
|
|
280
|
+
...event.conversation,
|
|
281
|
+
id: trimBrandedId(event.conversation.id),
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/** Interaction admission reuses Security Gate semantics; the click is activation. */
|
|
286
|
+
async enforceInteractionAccessGate(event) {
|
|
287
|
+
const accessLogger = this.options.accessLogger ?? this.options.logger;
|
|
288
|
+
if (!event.sender.id || event.sender.id === 'unknown') {
|
|
289
|
+
this.dropInteraction(accessLogger, event, 'unidentified_sender');
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
if (!event.conversation.id) {
|
|
293
|
+
this.dropInteraction(accessLogger, event, 'invalid_conversation');
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
let resolved;
|
|
297
|
+
try {
|
|
298
|
+
resolved = await this.options.accessResolver.resolve(event.channel, event.accountId);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
this.options.logger.warn('[channel-access] policy resolution failed', toLoggableError(error));
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
if (resolved.state !== 'present') {
|
|
305
|
+
this.dropInteraction(accessLogger, event, resolved.state === 'missing' ? 'missing_policy' : 'invalid_policy');
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
const decision = this.accessController.authorize({
|
|
309
|
+
conversationType: event.conversation.type,
|
|
310
|
+
senderId: event.sender.id,
|
|
311
|
+
conversationId: event.conversation.id,
|
|
312
|
+
policy: resolved.policy,
|
|
313
|
+
});
|
|
314
|
+
if (!decision.authorized) {
|
|
315
|
+
this.dropInteraction(accessLogger, event, decision.reason);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
dropInteraction(logger, event, reason) {
|
|
321
|
+
logger.info('[channel-access] interaction dropped', {
|
|
322
|
+
channel: event.channel,
|
|
323
|
+
account: event.accountId,
|
|
324
|
+
conversationType: event.conversation.type,
|
|
325
|
+
reason,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* One-time Agent-scoped command and model-hook setup. Installed onto an
|
|
330
|
+
* Agent's scoped context by every create/resolve and by the Session
|
|
331
|
+
* factory's recreate (borrow + create) so a fresh, resumed OR recreated
|
|
332
|
+
* session gets the channel commands and channel hooks before any driving
|
|
333
|
+
* happens. Harness still resolves the Session model at creation/resume.
|
|
334
|
+
* Channel images are NOT rewritten here: the inbound converter hands raw
|
|
335
|
+
* images to the Harness Attachment Store and the official image
|
|
336
|
+
* pipeline owns model-capability projection (vision variant / text-only
|
|
337
|
+
* deterministic placeholder), so the Agent-scoped history keeps the
|
|
338
|
+
* original ImageBlock.
|
|
339
|
+
*/
|
|
340
|
+
// Bound arrow: passed to create/resolve/borrowIfLive as the official
|
|
341
|
+
// AgentSetup (invoked as a bare setup(agentCtx)), so this must stay the
|
|
342
|
+
// bridge instance.
|
|
343
|
+
commandSetup = async (agentCtx) => {
|
|
344
|
+
const disposeCommands = await installChannelCommands(agentCtx, this.commandDeps);
|
|
345
|
+
const disposeModelSelection = this.modelSelection.install(agentCtx);
|
|
346
|
+
if (this.commandSetupsDisposed) {
|
|
347
|
+
await disposeCommands();
|
|
348
|
+
disposeModelSelection();
|
|
349
|
+
throw new Error('channel-harness command setup continued after bridge disposal');
|
|
350
|
+
}
|
|
351
|
+
this.commandDisposers.add(disposeCommands);
|
|
352
|
+
this.modelSelectionDisposers.add(disposeModelSelection);
|
|
353
|
+
// M4: Agent-scoped read_channel_attachment tool. Registered on the agent's
|
|
354
|
+
// own scope so it is disposed with the agent. Best-effort: a tool-install
|
|
355
|
+
// failure must never roll back the agent setup. The tool stays registered
|
|
356
|
+
// through the provider's OPTIONAL compatibility path while it
|
|
357
|
+
// is the only generic-attachment reader. The deprecated installTools name
|
|
358
|
+
// remains a fallback for one cycle; providers with neither hook skip it.
|
|
359
|
+
if (this.options.fileProvider) {
|
|
360
|
+
try {
|
|
361
|
+
await installAttachmentCompatibilityTools(this.options.fileProvider, agentCtx);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
this.options.logger.warn('[channel-harness] failed to install read_channel_attachment tool', error);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// M6: Agent-scoped send_channel_message tool. Only installed when the
|
|
368
|
+
// durable outbox is wired. Best-effort, mirroring the attachment tool.
|
|
369
|
+
if (this.options.outbox) {
|
|
370
|
+
try {
|
|
371
|
+
await installSendChannelMessageTool(agentCtx, { outbox: this.options.outbox });
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
this.options.logger.warn('[channel-harness] failed to install send_channel_message tool', error);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
/** Release this bridge's Agent-scoped command and model-hook registrations. */
|
|
379
|
+
async disposeCommandSetups() {
|
|
380
|
+
this.commandSetupsDisposed = true;
|
|
381
|
+
const commandDisposers = [...this.commandDisposers];
|
|
382
|
+
this.commandDisposers.clear();
|
|
383
|
+
const modelDisposers = [...this.modelSelectionDisposers];
|
|
384
|
+
this.modelSelectionDisposers.clear();
|
|
385
|
+
await Promise.all([
|
|
386
|
+
...commandDisposers.map((dispose) => dispose()),
|
|
387
|
+
...modelDisposers.map((dispose) => Promise.resolve(dispose())),
|
|
388
|
+
]);
|
|
389
|
+
}
|
|
390
|
+
conversationKey(event) {
|
|
391
|
+
return sessionKey({
|
|
392
|
+
channelId: event.channel,
|
|
393
|
+
accountId: event.accountId,
|
|
394
|
+
conversationId: event.conversation.id,
|
|
395
|
+
...(event.conversation.threadId ? { threadId: event.conversation.threadId } : {}),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
/** Conversation identity of an inbound event, as a bindable SessionKeyInput. */
|
|
399
|
+
conversationInput(event) {
|
|
400
|
+
return {
|
|
401
|
+
channelId: event.channel,
|
|
402
|
+
accountId: event.accountId,
|
|
403
|
+
conversationId: event.conversation.id,
|
|
404
|
+
// v3: stable conversation identity captured for the durable binding.
|
|
405
|
+
conversationType: event.conversation.type,
|
|
406
|
+
...(event.sender.id ? { senderId: event.sender.id } : {}),
|
|
407
|
+
...(event.conversation.threadId ? { threadId: event.conversation.threadId } : {}),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Whether the durable session behind an existing binding is MISSING — the
|
|
412
|
+
* stale condition behind both the EXPLICIT stale-binding repair (/new) and
|
|
413
|
+
* the loud SessionNotFoundError for every other request. A live agent is
|
|
414
|
+
* never stale (live-first: no persistence probe), and without a mounted
|
|
415
|
+
* sessionPersistence there is no durable identity to lose (ephemeral
|
|
416
|
+
* deployments recreate instead). One ATOMIC probe decides all three cases
|
|
417
|
+
* (live capability resolved once — no canResume/exists TOCTOU across a
|
|
418
|
+
* persistence HMR).
|
|
419
|
+
*/
|
|
420
|
+
async isDurableSessionMissing(binding) {
|
|
421
|
+
if (this.options.agentManager.getLiveAgent(binding.sessionId))
|
|
422
|
+
return false;
|
|
423
|
+
const probe = await this.options.agentManager.probePersisted(binding.sessionId);
|
|
424
|
+
return probe === 'missing';
|
|
425
|
+
}
|
|
426
|
+
/** Bindings without the field predate the stable policy; fail closed. */
|
|
427
|
+
bindingDurability(binding) {
|
|
428
|
+
return binding.durability ?? 'durable';
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* FAIL-CLOSED Access Gate. Returns the validated policy only when the
|
|
432
|
+
* message is admitted. `undefined` means DROP with NO side effect (agent /
|
|
433
|
+
* command / session / binding / workspace / generation / /stop fast path).
|
|
434
|
+
*
|
|
435
|
+
* Order:
|
|
436
|
+
* 1. Reserved claim suppression (/dsh-claim never reaches anything).
|
|
437
|
+
* 2. Identity validation: sender + conversation ids.
|
|
438
|
+
* 3. Resolve policy (missing/invalid -> drop, fail closed).
|
|
439
|
+
* 4. Authorize (security gate) + activate (activation gate).
|
|
440
|
+
*
|
|
441
|
+
* Logging follows the `channel-access` logger convention: minimal fields
|
|
442
|
+
* (channel / account / conversationType / reason), never message body,
|
|
443
|
+
* challenge code, raw payload or tokens.
|
|
444
|
+
*/
|
|
445
|
+
async enforceAccessGate(event, text) {
|
|
446
|
+
const accessLogger = this.options.accessLogger ?? this.options.logger;
|
|
447
|
+
// 1. Reserved owner-claim suppression: /dsh-claim must
|
|
448
|
+
// NEVER reach model / command dispatcher / Session / Binding, even when
|
|
449
|
+
// no access policy exists. Static drop — no policy read is needed.
|
|
450
|
+
if (isReservedClaimCommand(text)) {
|
|
451
|
+
accessLogger.debug('[channel-access] reserved claim message suppressed', {
|
|
452
|
+
channel: event.channel,
|
|
453
|
+
account: event.accountId,
|
|
454
|
+
});
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
// 2. Identity validation: sender.id must be a non-empty string
|
|
458
|
+
// and !== 'unknown'; conversation.id must be non-empty.
|
|
459
|
+
const senderId = event.sender.id;
|
|
460
|
+
const conversationId = event.conversation.id;
|
|
461
|
+
if (typeof senderId !== 'string' ||
|
|
462
|
+
senderId.length === 0 ||
|
|
463
|
+
senderId === 'unknown') {
|
|
464
|
+
this.dropInbound(accessLogger, event, 'unidentified_sender');
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
if (typeof conversationId !== 'string' || conversationId.length === 0) {
|
|
468
|
+
this.dropInbound(accessLogger, event, 'invalid_conversation');
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
// 3. Resolve the policy (fail closed).
|
|
472
|
+
let resolved;
|
|
473
|
+
try {
|
|
474
|
+
resolved = await this.options.accessResolver.resolve(event.channel, event.accountId);
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
this.options.logger.warn('[channel-access] policy resolution failed', toLoggableError(error));
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
if (resolved.state === 'missing') {
|
|
481
|
+
this.dropInbound(accessLogger, event, 'missing_policy');
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
if (resolved.state === 'invalid') {
|
|
485
|
+
this.dropInbound(accessLogger, event, 'invalid_policy');
|
|
486
|
+
return undefined;
|
|
487
|
+
}
|
|
488
|
+
// 4. Authorize (Security Gate) then activate (Activation Gate).
|
|
489
|
+
const decision = this.accessController.authorize({
|
|
490
|
+
conversationType: event.conversation.type,
|
|
491
|
+
senderId,
|
|
492
|
+
conversationId,
|
|
493
|
+
mentionedBot: event.message.activation?.mentionedBot,
|
|
494
|
+
policy: resolved.policy,
|
|
495
|
+
});
|
|
496
|
+
if (!decision.authorized) {
|
|
497
|
+
this.dropInbound(accessLogger, event, decision.reason);
|
|
498
|
+
return undefined;
|
|
499
|
+
}
|
|
500
|
+
if (!decision.activated) {
|
|
501
|
+
this.dropInbound(accessLogger, event, decision.reason);
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
return resolved.policy;
|
|
505
|
+
}
|
|
506
|
+
/** Log a fail-closed inbound drop with minimal plan-§42 fields. */
|
|
507
|
+
dropInbound(accessLogger, event, reason) {
|
|
508
|
+
accessLogger.info('[channel-access] inbound dropped', {
|
|
509
|
+
channel: event.channel,
|
|
510
|
+
account: event.accountId,
|
|
511
|
+
conversationType: event.conversation.type,
|
|
512
|
+
reason,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Queued (serialized) message handling for one conversation. Captures the
|
|
517
|
+
* generation at ENQUEUE time; /stop bumps it, so this callback drops out at
|
|
518
|
+
* either generation check and can never re-wake a stopped agent (spec §7).
|
|
519
|
+
*/
|
|
520
|
+
async handleQueuedMessage(event, key, text, parsed, generation) {
|
|
521
|
+
// Check #1: fast-drop work already invalidated by /stop (spec §7).
|
|
522
|
+
if (!this.isGenerationCurrent(key, generation))
|
|
523
|
+
return;
|
|
524
|
+
const route = this.options.agentRouter.resolve({
|
|
525
|
+
channelId: event.channel,
|
|
526
|
+
accountId: event.accountId,
|
|
527
|
+
conversationId: event.conversation.id,
|
|
528
|
+
});
|
|
529
|
+
const now = Date.now();
|
|
530
|
+
const parsedName = parsed?.name ?? null;
|
|
531
|
+
let binding = await this.options.bindingStore.get(key);
|
|
532
|
+
let archivedSessionId;
|
|
533
|
+
if (binding && this.options.workspaceResolver.isSessionArchived?.(binding.sessionId)) {
|
|
534
|
+
archivedSessionId = binding.sessionId;
|
|
535
|
+
this.options.logger.info('[channel-harness] archived binding will roll to a fresh session', {
|
|
536
|
+
sessionId: archivedSessionId,
|
|
537
|
+
bindingKey: key,
|
|
538
|
+
});
|
|
539
|
+
// Treat an archived binding like an absent binding for admission. The
|
|
540
|
+
// durable entry is intentionally left untouched until the Session factory
|
|
541
|
+
// commits its replacement, preserving rollback semantics on failure.
|
|
542
|
+
binding = undefined;
|
|
543
|
+
}
|
|
544
|
+
// /new is the ONE EXPLICIT stale-binding repair path — NOT a
|
|
545
|
+
// session-recovery branch: the user is explicitly authorizing abandonment
|
|
546
|
+
// of the old session (its persisted data is gone, so ordinary recovery
|
|
547
|
+
// would throw session-not-found) and creation of a fresh one that replaces
|
|
548
|
+
// the binding. Every other request on the stale binding still fails loud:
|
|
549
|
+
// the inconsistency is never auto-repaired.
|
|
550
|
+
if (binding &&
|
|
551
|
+
parsed &&
|
|
552
|
+
parsedName === 'new' &&
|
|
553
|
+
(await this.isDurableSessionMissing(binding))) {
|
|
554
|
+
// Arg contract mirrors the registered handler (用法:/new).
|
|
555
|
+
if (parsed.rawInput.trim().length > 0) {
|
|
556
|
+
await this.sendCommandNotice(event, '用法:/new');
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const staleSessionId = binding.sessionId;
|
|
560
|
+
this.options.logger.info('[channel-harness] /new repairs a stale binding (persisted session missing)', {
|
|
561
|
+
sessionId: staleSessionId,
|
|
562
|
+
bindingKey: key,
|
|
563
|
+
});
|
|
564
|
+
await this.sessionFactory.create(this.conversationInput(event), route);
|
|
565
|
+
// Clear the stale session's reverse cache (never live/owned -> the
|
|
566
|
+
// retire is a no-op dispose); the factory has already overwritten the
|
|
567
|
+
// binding with the fresh session.
|
|
568
|
+
await this.options.agentManager.retireSession(staleSessionId);
|
|
569
|
+
await this.sendCommandNotice(event, '旧会话数据已丢失,已开启新会话。');
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
let agentRef;
|
|
573
|
+
if (!binding) {
|
|
574
|
+
// --- Bootstrap: no receiving agent exists yet (spec §37/§38) -----------
|
|
575
|
+
if (parsed && parsedName === 'new') {
|
|
576
|
+
// First message is /new: boot a brand-new session directly — do NOT
|
|
577
|
+
// create session A and then run /new on it (no double-create). The
|
|
578
|
+
// arg contract mirrors the registered handler (用法:/new).
|
|
579
|
+
if (parsed.rawInput.trim().length > 0) {
|
|
580
|
+
await this.sendCommandNotice(event, '用法:/new');
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
await this.sessionFactory.create(this.conversationInput(event), route);
|
|
584
|
+
if (archivedSessionId) {
|
|
585
|
+
await this.options.agentManager.retireSession(archivedSessionId);
|
|
586
|
+
}
|
|
587
|
+
await this.sendCommandNotice(event, '已开启新会话。');
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
// Every other first message (ordinary text, /help, /status, /models,
|
|
591
|
+
// /model, or an unknown /foo) mints the session and continues below —
|
|
592
|
+
// first-message /help/status/models/model must work (spec §38). An
|
|
593
|
+
// unknown /foo is then rejected at command admission (Host parity;
|
|
594
|
+
// the session must exist first because channel commands register in the
|
|
595
|
+
// Agent scope and cannot be resolved without one).
|
|
596
|
+
const fresh = await this.sessionFactory.create(this.conversationInput(event), route);
|
|
597
|
+
binding = fresh.binding;
|
|
598
|
+
agentRef = fresh.agentRef;
|
|
599
|
+
if (archivedSessionId) {
|
|
600
|
+
await this.options.agentManager.retireSession(archivedSessionId);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
// --- Existing conversation: reconcile route snapshot + resolve ----------
|
|
605
|
+
if (!routesEqual(binding.route, route)) {
|
|
606
|
+
binding = { ...binding, route, updatedAt: now };
|
|
607
|
+
await this.options.bindingStore.put(binding);
|
|
608
|
+
}
|
|
609
|
+
// Existing-binding resolution follows the official Host resolver order
|
|
610
|
+
// (live agent -> persistence membership -> resume), never the reverse:
|
|
611
|
+
// ① a LIVE agent is borrowed FIRST — persistence is never scanned for
|
|
612
|
+
// an agent already live in this process (with thousands of sessions
|
|
613
|
+
// the per-inbound persistence scan would dominate);
|
|
614
|
+
// ② one ATOMIC probe decides the rest; availability is not durability:
|
|
615
|
+
// durable + unavailable -> fail loud (never recreate);
|
|
616
|
+
// ephemeral + unavailable -> recreate via the Session factory;
|
|
617
|
+
// ③ membership hit -> resume;
|
|
618
|
+
// ④ membership MISS -> durable binding => session-not-found, while an
|
|
619
|
+
// explicitly ephemeral binding may recreate the recorded id.
|
|
620
|
+
const borrowed = await this.options.agentManager.borrowIfLive(binding.sessionId, route, this.commandSetup);
|
|
621
|
+
if (borrowed) {
|
|
622
|
+
agentRef = borrowed;
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
const probe = await this.options.agentManager.probePersisted(binding.sessionId);
|
|
626
|
+
if (probe === 'unavailable' && this.bindingDurability(binding) === 'ephemeral') {
|
|
627
|
+
const recreated = await this.sessionFactory.recreate(binding, route);
|
|
628
|
+
binding = recreated.binding;
|
|
629
|
+
agentRef = recreated.agentRef;
|
|
630
|
+
}
|
|
631
|
+
else if (probe === 'present') {
|
|
632
|
+
agentRef = await this.options.agentManager.resolve(binding.sessionId, route, this.commandSetup);
|
|
633
|
+
}
|
|
634
|
+
else if (probe === 'unavailable') {
|
|
635
|
+
throw new PersistenceUnavailableError(binding.sessionId, key);
|
|
636
|
+
}
|
|
637
|
+
else if (this.bindingDurability(binding) === 'ephemeral') {
|
|
638
|
+
const recreated = await this.sessionFactory.recreate(binding, route);
|
|
639
|
+
binding = recreated.binding;
|
|
640
|
+
agentRef = recreated.agentRef;
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
throw new SessionNotFoundError(binding.sessionId, key);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
this.options.agentManager.registerBinding(binding);
|
|
647
|
+
}
|
|
648
|
+
// --- Command admission (Host parity) ------------------------------------
|
|
649
|
+
// Registered commands run on the Human Command Plane; an UNREGISTERED
|
|
650
|
+
// slash command is always rejected with a direct channel notice and never
|
|
651
|
+
// enters the Agent prompt.
|
|
652
|
+
if (parsed) {
|
|
653
|
+
const beforeSessionId = binding.sessionId;
|
|
654
|
+
const controller = new AbortController();
|
|
655
|
+
// `commands.execute` takes base64 composer images; channel command
|
|
656
|
+
// admission is text-only for now (command image parity is a later phase).
|
|
657
|
+
const execution = await this.options.ctx.commands.execute(agentRef.agent, text, [], controller.signal);
|
|
658
|
+
if (execution !== undefined) {
|
|
659
|
+
await this.renderCommandResult(event, execution.result);
|
|
660
|
+
// Generic post-command cleanup: whichever command switched the active
|
|
661
|
+
// binding gets its previous session retired. No command-name
|
|
662
|
+
// special-casing.
|
|
663
|
+
const currentBinding = await this.options.bindingStore.get(key);
|
|
664
|
+
if (currentBinding && currentBinding.sessionId !== beforeSessionId) {
|
|
665
|
+
await this.options.agentManager.retireSession(beforeSessionId);
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
// `execution === undefined` with syntax already parsed means the
|
|
670
|
+
// registry missed the name (`ctx.commands.find(agent, parsed.name)`
|
|
671
|
+
// returned nothing) — the official Host answers `unknown-command` and
|
|
672
|
+
// never forwards the line to the model.
|
|
673
|
+
this.options.logger.info('[channel-harness] rejected unknown command', {
|
|
674
|
+
channel: event.channel,
|
|
675
|
+
account: event.accountId,
|
|
676
|
+
conversationType: event.conversation.type,
|
|
677
|
+
command: parsed.name,
|
|
678
|
+
});
|
|
679
|
+
await this.sendCommandNotice(event, `未知命令:/${parsed.name},输入 /help 查看命令。`);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// Check #2: a /stop may have arrived while this message was resolving
|
|
683
|
+
// (spec §7) — do not re-wake a stopped agent.
|
|
684
|
+
if (!this.isGenerationCurrent(key, generation))
|
|
685
|
+
return;
|
|
686
|
+
// --- Ordinary message followup --------------------------------------------
|
|
687
|
+
const runId = randomUUID();
|
|
688
|
+
this.logInboundBinaryAvailability(event, binding.sessionId);
|
|
689
|
+
const userMessage = await toHarnessUserMessage(event, {
|
|
690
|
+
includeMetadataPrefix: this.options.config.includeMetadataPrefix,
|
|
691
|
+
saveImage: this.saveImageHook(event, binding.sessionId),
|
|
692
|
+
fileStore: this.fileStoreHook(event, binding.sessionId),
|
|
693
|
+
});
|
|
694
|
+
// Register the reply context keyed by the Harness UserMessage id strictly
|
|
695
|
+
// BEFORE followup.
|
|
696
|
+
this.options.replyContexts.register(userMessage.id, {
|
|
697
|
+
sessionId: binding.sessionId,
|
|
698
|
+
context: {
|
|
699
|
+
conversationType: event.conversation.type,
|
|
700
|
+
senderId: event.sender.id,
|
|
701
|
+
replyToMessageId: event.message.id,
|
|
702
|
+
// Platform reply handles such as DingTalk's per-message sessionWebhook
|
|
703
|
+
// are transient and must travel only with the triggering turn.
|
|
704
|
+
raw: event.raw,
|
|
705
|
+
runId,
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
agentRef.followup(userMessage);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* /stop FAST PATH (spec §4–§10). Runs OUTSIDE the serial chain:
|
|
712
|
+
* ① bump the generation FIRST (synchronous, before any await) so every
|
|
713
|
+
* queued/stale message on this conversation is invalidated;
|
|
714
|
+
* ② cancel the live agent — preferably by executing the registered /stop
|
|
715
|
+
* command (lifecycle recorded, behavior owned by the handler), with a
|
|
716
|
+
* direct `agent.cancel({ kind: 'user' })` fallback;
|
|
717
|
+
* ③ acknowledge immediately (never wait for `whenIdle`);
|
|
718
|
+
* ④ enqueue a fire-and-forget STOP BARRIER that, after prior chain work
|
|
719
|
+
* converges, re-cancels the LATEST binding's agent (covers the /new race,
|
|
720
|
+
* spec §9).
|
|
721
|
+
*/
|
|
722
|
+
async handleImmediateStop(event, key, text) {
|
|
723
|
+
// ① Generation bump must happen before any await (spec §8).
|
|
724
|
+
this.bumpGeneration(key);
|
|
725
|
+
// ② Resolve + cancel.
|
|
726
|
+
const binding = await this.options.bindingStore.get(key);
|
|
727
|
+
if (binding) {
|
|
728
|
+
const agent = this.options.agentManager.getLiveAgent(binding.sessionId);
|
|
729
|
+
if (agent) {
|
|
730
|
+
try {
|
|
731
|
+
const controller = new AbortController();
|
|
732
|
+
// `commands.execute` takes base64 composer images; none accompany
|
|
733
|
+
// an inbound IM stop command.
|
|
734
|
+
const execution = await this.options.ctx.commands.execute(agent, text, [], controller.signal);
|
|
735
|
+
if (execution !== undefined) {
|
|
736
|
+
await this.renderCommandResult(event, execution.result);
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
agent.cancel({ kind: 'user' });
|
|
740
|
+
await this.sendCommandNotice(event, '已停止当前任务。');
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
agent.cancel({ kind: 'user' });
|
|
745
|
+
await this.sendCommandNotice(event, '已停止当前任务。');
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
// Binding exists but no process-local live agent (cold/resumed
|
|
750
|
+
// elsewhere): nothing to cancel here; the barrier re-checks below.
|
|
751
|
+
await this.sendCommandNotice(event, '已停止当前任务。');
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
// ③ No session: never create one for /stop (spec §39).
|
|
756
|
+
await this.sendCommandNotice(event, '当前没有可停止的任务。');
|
|
757
|
+
}
|
|
758
|
+
// ④ Stop barrier: after existing chain work converges, re-read the LATEST
|
|
759
|
+
// binding and cancel its agent (spec §9).
|
|
760
|
+
void this.enqueueConversation(key, async () => {
|
|
761
|
+
const latestBinding = await this.options.bindingStore.get(key);
|
|
762
|
+
if (!latestBinding)
|
|
763
|
+
return;
|
|
764
|
+
this.options.agentManager.getLiveAgent(latestBinding.sessionId)?.cancel({ kind: 'user' });
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
/** Generation helpers (spec §6). */
|
|
768
|
+
generationOf(key) {
|
|
769
|
+
return this.conversationGenerations.get(key) ?? 0;
|
|
770
|
+
}
|
|
771
|
+
bumpGeneration(key) {
|
|
772
|
+
const next = this.generationOf(key) + 1;
|
|
773
|
+
this.conversationGenerations.set(key, next);
|
|
774
|
+
return next;
|
|
775
|
+
}
|
|
776
|
+
isGenerationCurrent(key, generation) {
|
|
777
|
+
return this.generationOf(key) === generation;
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Build the converter's per-message image hook: the official Harness
|
|
781
|
+
* attachment seam stays the sole authority for the durable image ref, and
|
|
782
|
+
* — best-effort — the same bytes are MIRRORED into the private channel
|
|
783
|
+
* asset store under the harness attachment id (the model-visible
|
|
784
|
+
* `sha256:…`), so `send_channel_message` can resolve the image for
|
|
785
|
+
* outbound sends (issue #7). A mirror failure never breaks delivery: the
|
|
786
|
+
* ref is already committed and the converter falls back exactly as before.
|
|
787
|
+
*/
|
|
788
|
+
saveImageHook(event, sessionId) {
|
|
789
|
+
const commit = this.options.saveImage;
|
|
790
|
+
if (!commit)
|
|
791
|
+
return undefined;
|
|
792
|
+
const mirror = this.options.fileProvider?.storeImage?.bind(this.options.fileProvider);
|
|
793
|
+
if (!mirror)
|
|
794
|
+
return commit;
|
|
795
|
+
return async (input) => {
|
|
796
|
+
const ref = await commit(input);
|
|
797
|
+
try {
|
|
798
|
+
await mirror({
|
|
799
|
+
sessionId,
|
|
800
|
+
channelId: event.channel,
|
|
801
|
+
accountId: event.accountId,
|
|
802
|
+
conversationId: event.conversation.id,
|
|
803
|
+
...(event.conversation.type ? { conversationType: event.conversation.type } : {}),
|
|
804
|
+
...(event.conversation.threadId ? { threadId: event.conversation.threadId } : {}),
|
|
805
|
+
messageId: event.message.id,
|
|
806
|
+
}, {
|
|
807
|
+
attachmentId: ref.attachmentId,
|
|
808
|
+
data: input.data,
|
|
809
|
+
mimeType: input.mediaType,
|
|
810
|
+
...(input.name === undefined ? {} : { name: input.name }),
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
catch (error) {
|
|
814
|
+
this.options.logger.warn('[channel-harness] inbound image mirror failed', {
|
|
815
|
+
sessionId,
|
|
816
|
+
attachmentId: ref.attachmentId,
|
|
817
|
+
error: toLoggableError(error),
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
return ref;
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Build the converter's optional file/audio/video store hook. Absent
|
|
825
|
+
* `fileProvider` -> no hook -> the converter keeps `[file: name]`
|
|
826
|
+
* placeholders (unchanged fallback). The hook binds the current binding's
|
|
827
|
+
* session + event identity so a stored asset is correctly session-ACL'd.
|
|
828
|
+
*/
|
|
829
|
+
fileStoreHook(event, sessionId) {
|
|
830
|
+
if (!this.options.fileProvider)
|
|
831
|
+
return undefined;
|
|
832
|
+
const provider = this.options.fileProvider;
|
|
833
|
+
return async (part) => {
|
|
834
|
+
const fields = this.attachmentLogFields(event, sessionId, part);
|
|
835
|
+
try {
|
|
836
|
+
const descriptor = await provider.store({
|
|
837
|
+
sessionId,
|
|
838
|
+
channelId: event.channel,
|
|
839
|
+
accountId: event.accountId,
|
|
840
|
+
conversationId: event.conversation.id,
|
|
841
|
+
...(event.conversation.type ? { conversationType: event.conversation.type } : {}),
|
|
842
|
+
...(event.conversation.threadId ? { threadId: event.conversation.threadId } : {}),
|
|
843
|
+
messageId: event.message.id,
|
|
844
|
+
}, part);
|
|
845
|
+
if (!descriptor) {
|
|
846
|
+
this.options.logger.warn('[channel-harness] inbound attachment was not stored', fields);
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
this.options.logger.info('[channel-harness] inbound attachment stored', {
|
|
850
|
+
...fields,
|
|
851
|
+
attachmentId: descriptor.attachmentId,
|
|
852
|
+
bytes: descriptor.bytes,
|
|
853
|
+
readable: descriptor.readable,
|
|
854
|
+
});
|
|
855
|
+
return descriptor;
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
this.options.logger.warn('[channel-harness] inbound attachment storage failed', {
|
|
859
|
+
...fields,
|
|
860
|
+
error: toLoggableError(error),
|
|
861
|
+
});
|
|
862
|
+
return undefined;
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
/** Log the adapter-to-asset-store boundary once for every binary inbound part. */
|
|
867
|
+
logInboundBinaryAvailability(event, sessionId) {
|
|
868
|
+
for (const part of event.message.content) {
|
|
869
|
+
if (part.type !== 'file' && part.type !== 'audio' && part.type !== 'video')
|
|
870
|
+
continue;
|
|
871
|
+
if (part.localData?.byteLength)
|
|
872
|
+
continue;
|
|
873
|
+
this.options.logger.warn('[channel-harness] inbound attachment has no local bytes', {
|
|
874
|
+
...this.attachmentLogFields(event, sessionId, part),
|
|
875
|
+
hasUrl: Boolean(part.url),
|
|
876
|
+
reason: 'adapter did not provide downloaded bytes',
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
attachmentLogFields(event, sessionId, part) {
|
|
881
|
+
return {
|
|
882
|
+
channel: event.channel,
|
|
883
|
+
accountId: event.accountId,
|
|
884
|
+
conversationId: event.conversation.id,
|
|
885
|
+
sessionId,
|
|
886
|
+
messageId: event.message.id,
|
|
887
|
+
kind: part.type,
|
|
888
|
+
name: part.type === 'file' ? part.name : undefined,
|
|
889
|
+
mimeType: part.mimeType,
|
|
890
|
+
localBytes: part.localData?.byteLength,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* The `commandDeps.startNewSession` implementation. Resolves the
|
|
895
|
+
* conversation from the CURRENT binding of the invoking agent (the session id
|
|
896
|
+
* IS the agent id), then asks the Session factory to mint a NEW session id
|
|
897
|
+
* (never a copy of the old one). The factory also attaches the new session to
|
|
898
|
+
* the same channel Workspace and registers its binding. The OLD agent is NOT
|
|
899
|
+
* disposed here; the bridge's post-command retire handles that. If the
|
|
900
|
+
* factory throws, the old binding stays untouched.
|
|
901
|
+
*/
|
|
902
|
+
async startNewSession(agent) {
|
|
903
|
+
const sessionId = String(agent.id);
|
|
904
|
+
const oldBinding = this.options.agentManager.bindingFor(sessionId);
|
|
905
|
+
if (!oldBinding) {
|
|
906
|
+
throw new Error("startNewSession: no binding for session '" + sessionId + "'");
|
|
907
|
+
}
|
|
908
|
+
// Re-resolve through the current routing rules before creating the Session,
|
|
909
|
+
// so /new follows today's overrides rather than the old binding snapshot.
|
|
910
|
+
const route = this.options.agentRouter.resolve({
|
|
911
|
+
channelId: oldBinding.channelId,
|
|
912
|
+
accountId: oldBinding.accountId,
|
|
913
|
+
conversationId: oldBinding.conversationId,
|
|
914
|
+
});
|
|
915
|
+
await this.sessionFactory.create({
|
|
916
|
+
channelId: oldBinding.channelId,
|
|
917
|
+
accountId: oldBinding.accountId,
|
|
918
|
+
conversationId: oldBinding.conversationId,
|
|
919
|
+
conversationType: oldBinding.conversationType,
|
|
920
|
+
...(oldBinding.senderId ? { senderId: oldBinding.senderId } : {}),
|
|
921
|
+
...(oldBinding.threadId ? { threadId: oldBinding.threadId } : {}),
|
|
922
|
+
}, route);
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Deliver a command-plane notice directly through the channel adapter — never
|
|
926
|
+
* through ReplyRouter and never as an assistant/model message.
|
|
927
|
+
*/
|
|
928
|
+
async sendCommandNotice(event, text) {
|
|
929
|
+
const adapter = this.options.getAdapter(event.channel);
|
|
930
|
+
if (!adapter) {
|
|
931
|
+
this.options.logger.warn(`[channel-harness] no adapter for channel '${event.channel}' — could not deliver command notice`);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
await adapter.send(this.targetForEvent(event), { text });
|
|
935
|
+
}
|
|
936
|
+
/** Render a settled CommandResult to the channel (success/error text). */
|
|
937
|
+
async renderCommandResult(event, result) {
|
|
938
|
+
if (result.kind === 'error') {
|
|
939
|
+
await this.sendCommandNotice(event, result.text);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (result.text) {
|
|
943
|
+
await this.sendCommandNotice(event, result.text);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
/** Build the outbound ChannelTarget from the inbound conversation + message. */
|
|
947
|
+
targetForEvent(event) {
|
|
948
|
+
const target = {
|
|
949
|
+
channelId: event.channel,
|
|
950
|
+
accountId: event.accountId,
|
|
951
|
+
conversationId: event.conversation.id,
|
|
952
|
+
conversationType: event.conversation.type,
|
|
953
|
+
raw: event.raw,
|
|
954
|
+
...(event.conversation.threadId
|
|
955
|
+
? { threadId: event.conversation.threadId, replyToMessageId: event.message.id }
|
|
956
|
+
: { replyToMessageId: event.message.id }),
|
|
957
|
+
};
|
|
958
|
+
return target;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
//# sourceMappingURL=bridge.js.map
|