mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 05:23:18 +08:00
feat: add native result-file delivery
This commit is contained in:
parent
2803bbcbab
commit
d472dd5a75
65 changed files with 10205 additions and 255 deletions
|
|
@ -21,9 +21,11 @@ export function createEditableMessageStream({
|
|||
create,
|
||||
edit,
|
||||
sendRemainder,
|
||||
messageIdForResult = () => null,
|
||||
logger = console,
|
||||
}) {
|
||||
let messageId;
|
||||
const providerMessageIds = [];
|
||||
let pending = null;
|
||||
let timer = null;
|
||||
let inFlight = null;
|
||||
|
|
@ -49,8 +51,17 @@ export function createEditableMessageStream({
|
|||
};
|
||||
|
||||
return {
|
||||
get messageId() {
|
||||
return messageId;
|
||||
},
|
||||
get providerMessageIds() {
|
||||
return [...providerMessageIds];
|
||||
},
|
||||
async start() {
|
||||
messageId = await create(initialText);
|
||||
if ((typeof messageId === 'string' && messageId.trim()) || Number.isSafeInteger(messageId)) {
|
||||
providerMessageIds.push(String(messageId));
|
||||
}
|
||||
return this;
|
||||
},
|
||||
update(text) {
|
||||
|
|
@ -69,7 +80,13 @@ export function createEditableMessageStream({
|
|||
const first = chunks[0] ?? '处理完成。';
|
||||
if (first !== lastSent) await edit(messageId, first);
|
||||
lastSent = first;
|
||||
for (const chunk of chunks.slice(1)) await sendRemainder(chunk);
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
const result = await sendRemainder(chunk);
|
||||
const id = messageIdForResult(result);
|
||||
if ((typeof id === 'string' && id.trim()) || Number.isSafeInteger(id)) {
|
||||
providerMessageIds.push(String(id));
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
|
||||
import { outboundArtifactRegistry } from './semantic/artifact.mjs';
|
||||
|
||||
// Every channel plugin runs in the same Host process. Sharing ownership by
|
||||
// Harness origin prevents two channel-specific clients bound to one Session
|
||||
|
|
@ -1025,6 +1026,7 @@ export class HarnessClient {
|
|||
const timeoutMs = options.timeoutMs ?? 600_000;
|
||||
const signal = options.signal;
|
||||
const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
|
||||
const onArtifact = typeof options.onArtifact === 'function' ? options.onArtifact : null;
|
||||
const onInteraction = typeof options.onInteraction === 'function'
|
||||
? options.onInteraction
|
||||
: undefined;
|
||||
|
|
@ -1071,11 +1073,34 @@ export class HarnessClient {
|
|||
}
|
||||
: null;
|
||||
let interactionTask = null;
|
||||
let artifactsDelivered = false;
|
||||
let deliveredArtifactCount = 0;
|
||||
|
||||
const deliverArtifacts = async () => {
|
||||
if (!onArtifact || artifactsDelivered || tracker.turn === null) {
|
||||
return deliveredArtifactCount;
|
||||
}
|
||||
artifactsDelivered = true;
|
||||
const artifacts = outboundArtifactRegistry.take(sessionId, tracker.turn, { signal });
|
||||
for (const artifact of artifacts) {
|
||||
try {
|
||||
await onArtifact(artifact);
|
||||
deliveredArtifactCount += 1;
|
||||
} catch (error) {
|
||||
outboundArtifactRegistry.release(artifact);
|
||||
console.warn(`[${this.#logPrefix}] ignored an artifact handoff failure:`, error.message);
|
||||
}
|
||||
}
|
||||
return deliveredArtifactCount;
|
||||
};
|
||||
|
||||
if (ownership) {
|
||||
this.#registerInteractionOwnership(sessionId, ownership);
|
||||
this.#registerControlOwnership(ownership);
|
||||
}
|
||||
// This is resource ownership, not a feature Gate: it lets the Host retain
|
||||
// this Turn's snapshots until the channel has polled and claimed them.
|
||||
const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
|
||||
|
||||
try {
|
||||
if (interactionSignal) {
|
||||
|
|
@ -1134,7 +1159,15 @@ export class HarnessClient {
|
|||
}
|
||||
}
|
||||
if (!tracker.finished) continue;
|
||||
if (tracker.answer) return tracker.answer;
|
||||
// An accepted /stop revokes attachment delivery even when Harness
|
||||
// preserved a useful partial text answer for the existing UX.
|
||||
const artifactCount = ownership?.stopRequested
|
||||
? 0
|
||||
: await deliverArtifacts();
|
||||
if (tracker.answer) {
|
||||
return tracker.answer;
|
||||
}
|
||||
if (artifactCount > 0) return '';
|
||||
if (ownership?.stopRequested) throw turnStoppedError();
|
||||
throw new Error(
|
||||
`Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
|
||||
|
|
@ -1145,15 +1178,19 @@ export class HarnessClient {
|
|||
// Once cancellation was accepted, transport/poll failures and timeouts
|
||||
// describe the convergence of that stop, not an unrelated ask failure.
|
||||
if (!ownership?.stopRequested) throw error;
|
||||
if (tracker.answer) return tracker.answer;
|
||||
if (tracker.answer) {
|
||||
return tracker.answer;
|
||||
}
|
||||
if (error?.code === 'turn-stopped') throw error;
|
||||
throw turnStoppedError();
|
||||
}
|
||||
} finally {
|
||||
closeArtifactConsumer();
|
||||
if (ownership) {
|
||||
this.#unregisterControlOwnership(ownership);
|
||||
this.#unregisterInteractionOwnership(sessionId, ownership);
|
||||
}
|
||||
if (tracker.turn !== null) outboundArtifactRegistry.discard(sessionId, tracker.turn);
|
||||
interactionController?.abort(new DOMException('Harness turn finished', 'AbortError'));
|
||||
if (interactionTask) await interactionTask.catch(() => undefined);
|
||||
}
|
||||
|
|
|
|||
748
src/channels/shared/semantic/artifact.mjs
Normal file
748
src/channels/shared/semantic/artifact.mjs
Normal file
|
|
@ -0,0 +1,748 @@
|
|||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { copyFile, lstat, mkdtemp, open, realpath, unlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, extname, isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
export const OUTBOUND_ARTIFACT_TOOL = 'dsh_im_return_file';
|
||||
|
||||
const ARTIFACT_KIND = 'dsh-im-outbound-artifact';
|
||||
const ARTIFACT_READ_CHUNK_BYTES = 64 * 1024;
|
||||
const MIME_BY_EXTENSION = new Map([
|
||||
['.csv', 'text/csv'],
|
||||
['.doc', 'application/msword'],
|
||||
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
['.gif', 'image/gif'],
|
||||
['.html', 'text/html'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.json', 'application/json'],
|
||||
['.md', 'text/markdown'],
|
||||
['.pdf', 'application/pdf'],
|
||||
['.png', 'image/png'],
|
||||
['.rar', 'application/vnd.rar'],
|
||||
['.txt', 'text/plain'],
|
||||
['.webp', 'image/webp'],
|
||||
['.xls', 'application/vnd.ms-excel'],
|
||||
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||
['.xml', 'application/xml'],
|
||||
['.zip', 'application/zip'],
|
||||
]);
|
||||
|
||||
const artifactStorage = new WeakMap();
|
||||
const materializedArtifactSources = new WeakMap();
|
||||
const artifactProviderSettlements = new WeakMap();
|
||||
let managedSnapshotDirectoryPromise;
|
||||
|
||||
function managedSnapshotDirectory() {
|
||||
managedSnapshotDirectoryPromise ??= mkdtemp(join(tmpdir(), 'dsh-im-outbound-'));
|
||||
return managedSnapshotDirectoryPromise;
|
||||
}
|
||||
|
||||
function artifactError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function currentTurn(agent) {
|
||||
const events = agent?.session?.events;
|
||||
if (!Array.isArray(events)) return null;
|
||||
let turn = null;
|
||||
for (const event of events) {
|
||||
if (event?.type === 'turn/start') {
|
||||
turn = Number.isInteger(event.data?.turn) ? event.data.turn : null;
|
||||
} else if (event?.type === 'turn/end' && event.data?.turn === turn) {
|
||||
turn = null;
|
||||
}
|
||||
}
|
||||
return Number.isInteger(turn) && turn >= 0 ? turn : null;
|
||||
}
|
||||
|
||||
function sessionIdOf(session) {
|
||||
const sessionId = session?.id ?? session?.header?.id;
|
||||
return typeof sessionId === 'string' && sessionId ? sessionId : null;
|
||||
}
|
||||
|
||||
function turnKey(sessionId, turn) {
|
||||
return `${sessionId}\u0000${turn}`;
|
||||
}
|
||||
|
||||
function promptKey(sessionId, promptRpcId) {
|
||||
return `${sessionId}\u0000${promptRpcId}`;
|
||||
}
|
||||
|
||||
function safeFileName(value) {
|
||||
const cleaned = String(value ?? '')
|
||||
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
||||
.replace(/\p{Cf}/gu, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!cleaned) return 'result.bin';
|
||||
return [...cleaned].slice(0, 255).join('');
|
||||
}
|
||||
|
||||
function mediaTypeFor(name) {
|
||||
return MIME_BY_EXTENSION.get(extname(name).toLowerCase()) ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function sameIdentity(left, right) {
|
||||
return typeof left?.dev === 'bigint'
|
||||
&& typeof left?.ino === 'bigint'
|
||||
&& typeof left?.size === 'bigint'
|
||||
&& typeof left?.mtimeNs === 'bigint'
|
||||
&& typeof left?.ctimeNs === 'bigint'
|
||||
&& left.dev === right?.dev
|
||||
&& left.ino === right?.ino
|
||||
&& left.size === right?.size
|
||||
&& left.mtimeNs === right?.mtimeNs
|
||||
&& left.ctimeNs === right?.ctimeNs;
|
||||
}
|
||||
|
||||
async function hashFile(path, signal) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(path, fsConstants.O_RDONLY);
|
||||
const hash = createHash('sha256');
|
||||
const stream = handle.createReadStream({
|
||||
autoClose: false,
|
||||
highWaterMark: ARTIFACT_READ_CHUNK_BYTES,
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
for await (const chunk of stream) {
|
||||
signal?.throwIfAborted();
|
||||
hash.update(chunk);
|
||||
}
|
||||
return hash.digest('hex');
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read exactly the observed file size and prove EOF. No project-level size or time limit applies. */
|
||||
export async function readExactArtifactFile(handle, expectedSize, {
|
||||
signal,
|
||||
errorCode = 'artifact-changed',
|
||||
errorMessage = 'The result file changed while it was being read.',
|
||||
} = {}) {
|
||||
const changed = () => artifactError(errorCode, errorMessage);
|
||||
if (!Number.isSafeInteger(expectedSize) || expectedSize < 0) throw changed();
|
||||
signal?.throwIfAborted();
|
||||
|
||||
let bytes;
|
||||
try {
|
||||
bytes = Buffer.allocUnsafe(expectedSize);
|
||||
} catch {
|
||||
throw changed();
|
||||
}
|
||||
|
||||
if (typeof handle?.createReadStream === 'function') {
|
||||
const stream = handle.createReadStream({
|
||||
autoClose: false,
|
||||
start: 0,
|
||||
// Node's end offset is inclusive, so this reads one byte beyond the
|
||||
// observed size when the file grows and catches the change.
|
||||
end: expectedSize,
|
||||
highWaterMark: ARTIFACT_READ_CHUNK_BYTES,
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
let offset = 0;
|
||||
for await (const chunk of stream) {
|
||||
signal?.throwIfAborted();
|
||||
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (offset + part.length > expectedSize) throw changed();
|
||||
part.copy(bytes, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
if (offset !== expectedSize) throw changed();
|
||||
return bytes;
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
while (offset < expectedSize) {
|
||||
signal?.throwIfAborted();
|
||||
const length = Math.min(ARTIFACT_READ_CHUNK_BYTES, expectedSize - offset);
|
||||
const result = await handle.read(bytes, offset, length, offset);
|
||||
const bytesRead = Number(result?.bytesRead ?? 0);
|
||||
if (!Number.isInteger(bytesRead) || bytesRead <= 0 || bytesRead > length) throw changed();
|
||||
offset += bytesRead;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const probe = Buffer.allocUnsafe(1);
|
||||
if ((await handle.read(probe, 0, 1, expectedSize))?.bytesRead !== 0) throw changed();
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function snapshotFile(workspace, requestedPath, signal) {
|
||||
signal?.throwIfAborted();
|
||||
let storagePath;
|
||||
try {
|
||||
const candidate = isAbsolute(requestedPath)
|
||||
? requestedPath
|
||||
: resolve(workspace, requestedPath);
|
||||
const canonicalPath = await realpath(candidate);
|
||||
const source = await lstat(canonicalPath, { bigint: true });
|
||||
if (!source.isFile()) {
|
||||
throw artifactError('artifact-not-file', 'The requested path is not a file.');
|
||||
}
|
||||
|
||||
const directory = await managedSnapshotDirectory();
|
||||
storagePath = join(directory, `${randomUUID()}.artifact`);
|
||||
await copyFile(canonicalPath, storagePath, fsConstants.COPYFILE_EXCL);
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const snapshot = await lstat(storagePath, { bigint: true });
|
||||
const size = Number(snapshot.size);
|
||||
if (!snapshot.isFile() || !Number.isSafeInteger(size) || size < 0) {
|
||||
throw artifactError('artifact-unavailable', 'The file could not be prepared for delivery.');
|
||||
}
|
||||
const fileName = safeFileName(basename(candidate));
|
||||
return Object.freeze({
|
||||
fileName,
|
||||
mediaType: mediaTypeFor(fileName),
|
||||
size,
|
||||
digest: await hashFile(storagePath, signal),
|
||||
storagePath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (storagePath) await unlink(storagePath).catch(() => undefined);
|
||||
if (error?.code?.startsWith?.('artifact-')) throw error;
|
||||
if (signal?.aborted) throw signal.reason ?? error;
|
||||
throw artifactError('artifact-unavailable', 'The requested file is unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
function publicArtifact(artifact) {
|
||||
return Object.freeze({
|
||||
artifactId: artifact.artifactId,
|
||||
fileName: artifact.fileName,
|
||||
size: artifact.size,
|
||||
});
|
||||
}
|
||||
|
||||
function artifactKey(artifact) {
|
||||
return artifact.artifactId;
|
||||
}
|
||||
|
||||
function cleanupArtifactStorage(artifact) {
|
||||
const storage = artifactStorage.get(artifact);
|
||||
if (!storage) return;
|
||||
storage.releaseRequested = true;
|
||||
if (storage.materializing > 0) {
|
||||
storage.cleanupRequested = true;
|
||||
return;
|
||||
}
|
||||
const pending = [...artifactProviderSettlements.get(artifact) ?? []];
|
||||
if (pending.length > 0) {
|
||||
if (!storage.cleanupDeferred) {
|
||||
storage.cleanupDeferred = true;
|
||||
void Promise.allSettled(pending).finally(() => {
|
||||
storage.cleanupDeferred = false;
|
||||
cleanupArtifactStorage(artifact);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
artifactStorage.delete(artifact);
|
||||
artifactProviderSettlements.delete(artifact);
|
||||
storage.onCleanup?.();
|
||||
void unlink(storage.path).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds successful tool results until the channel that owns the Session Turn
|
||||
* collects them. It does not decide which files a user may send.
|
||||
*/
|
||||
export class OutboundArtifactRegistry {
|
||||
#turns = new Map();
|
||||
#stagedTurns = new Map();
|
||||
#claimedTurns = new Map();
|
||||
#claimSignals = new Map();
|
||||
#signalClaims = new Map();
|
||||
#consumersByPrompt = new Map();
|
||||
#consumersByTurn = new Map();
|
||||
#openTurns = new Map();
|
||||
#uuid;
|
||||
|
||||
constructor({ uuid = randomUUID } = {}) {
|
||||
if (typeof uuid !== 'function') throw new TypeError('uuid must be a function');
|
||||
this.#uuid = uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one channel request to the Turn it starts. This owns cleanup only:
|
||||
* it never changes tool visibility or decides whether a file may be sent.
|
||||
*/
|
||||
openConsumer(sessionId, promptRpcId) {
|
||||
if (typeof sessionId !== 'string' || !sessionId
|
||||
|| typeof promptRpcId !== 'string' || !promptRpcId) {
|
||||
throw new TypeError('sessionId and promptRpcId are required');
|
||||
}
|
||||
const key = promptKey(sessionId, promptRpcId);
|
||||
const consumer = {
|
||||
sessionId,
|
||||
promptRpcId,
|
||||
turn: null,
|
||||
released: false,
|
||||
};
|
||||
this.#consumersByPrompt.set(key, consumer);
|
||||
return () => {
|
||||
if (consumer.released) return;
|
||||
consumer.released = true;
|
||||
if (this.#consumersByPrompt.get(key) === consumer) {
|
||||
this.#consumersByPrompt.delete(key);
|
||||
}
|
||||
if (consumer.turn !== null) {
|
||||
const keyForTurn = turnKey(sessionId, consumer.turn);
|
||||
if (this.#consumersByTurn.get(keyForTurn) === consumer) {
|
||||
this.#consumersByTurn.delete(keyForTurn);
|
||||
}
|
||||
// Claimed artifacts have already crossed into the provider pipeline and
|
||||
// are released there. discard() only removes unclaimed work.
|
||||
this.discard(sessionId, consumer.turn);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Observe durable Session events solely to terminate unclaimed snapshots. */
|
||||
observeSessionEvent(session, event) {
|
||||
const sessionId = sessionIdOf(session);
|
||||
if (!sessionId || !event || typeof event !== 'object') return;
|
||||
if (event.type === 'turn/start') {
|
||||
const turn = event.data?.turn;
|
||||
if (Number.isInteger(turn) && turn >= 0) this.#openTurns.set(sessionId, turn);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'user/message') {
|
||||
const rpcId = event.data?.source?.rpcId;
|
||||
const turn = this.#openTurns.get(sessionId) ?? currentTurn({ session });
|
||||
if (typeof rpcId !== 'string' || !rpcId || !Number.isInteger(turn)) return;
|
||||
this.#openTurns.set(sessionId, turn);
|
||||
const consumer = this.#consumersByPrompt.get(promptKey(sessionId, rpcId));
|
||||
if (!consumer || consumer.released) return;
|
||||
consumer.turn = turn;
|
||||
this.#consumersByTurn.set(turnKey(sessionId, turn), consumer);
|
||||
return;
|
||||
}
|
||||
if (event.type !== 'turn/end') return;
|
||||
const turn = event.data?.turn;
|
||||
if (!Number.isInteger(turn) || turn < 0) return;
|
||||
if (this.#openTurns.get(sessionId) === turn) this.#openTurns.delete(sessionId);
|
||||
const consumer = this.#consumersByTurn.get(turnKey(sessionId, turn));
|
||||
if (!consumer || consumer.released) this.discard(sessionId, turn);
|
||||
}
|
||||
|
||||
/** A disposed Session cannot have another channel consumer claim its files. */
|
||||
disposeSession(session) {
|
||||
const sessionId = sessionIdOf(session);
|
||||
if (!sessionId) return;
|
||||
const prefix = `${sessionId}\u0000`;
|
||||
const artifacts = new Set();
|
||||
for (const entries of [this.#turns, this.#stagedTurns]) {
|
||||
for (const [key, turnArtifacts] of entries) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
for (const artifact of turnArtifacts.values()) artifacts.add(artifact);
|
||||
entries.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, consumer] of this.#consumersByPrompt) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
consumer.released = true;
|
||||
this.#consumersByPrompt.delete(key);
|
||||
}
|
||||
for (const key of this.#consumersByTurn.keys()) {
|
||||
if (key.startsWith(prefix)) this.#consumersByTurn.delete(key);
|
||||
}
|
||||
this.#openTurns.delete(sessionId);
|
||||
for (const artifact of artifacts) cleanupArtifactStorage(artifact);
|
||||
}
|
||||
|
||||
async stage(args, exec) {
|
||||
const requestedPath = args?.path;
|
||||
if (typeof requestedPath !== 'string' || !requestedPath.trim()) {
|
||||
throw new TypeError('A file path is required.');
|
||||
}
|
||||
const agent = exec?.agent;
|
||||
const sessionId = agent?.session?.header?.id;
|
||||
const workspace = agent?.session?.header?.cwd;
|
||||
const turn = currentTurn(agent);
|
||||
if (typeof sessionId !== 'string' || !sessionId
|
||||
|| typeof workspace !== 'string' || !workspace || turn === null) {
|
||||
throw artifactError(
|
||||
'artifact-context-required',
|
||||
'A live Harness Session is required to return a file.',
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = await snapshotFile(workspace, requestedPath, exec?.signal);
|
||||
const { storagePath, ...snapshotMetadata } = snapshot;
|
||||
const artifact = Object.freeze({
|
||||
kind: ARTIFACT_KIND,
|
||||
schemaVersion: 1,
|
||||
artifactId: this.#uuid(),
|
||||
deliveryKey: this.#uuid(),
|
||||
...snapshotMetadata,
|
||||
source: 'managed-temp',
|
||||
registeredBy: Object.freeze({
|
||||
kind: 'tool-result',
|
||||
eventId: typeof exec?.callId === 'string' ? exec.callId : 'unknown',
|
||||
toolName: OUTBOUND_ARTIFACT_TOOL,
|
||||
}),
|
||||
origin: Object.freeze({
|
||||
sessionId,
|
||||
turn,
|
||||
callId: typeof exec?.callId === 'string' ? exec.callId : null,
|
||||
}),
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
artifactStorage.set(artifact, {
|
||||
path: storagePath,
|
||||
materializing: 0,
|
||||
materialized: false,
|
||||
releaseRequested: false,
|
||||
cleanupRequested: false,
|
||||
cleanupDeferred: false,
|
||||
onCleanup: () => this.#forgetArtifact(artifact),
|
||||
});
|
||||
const keyForTurn = turnKey(sessionId, turn);
|
||||
let staged = this.#stagedTurns.get(keyForTurn);
|
||||
if (!staged) {
|
||||
staged = new Map();
|
||||
this.#stagedTurns.set(keyForTurn, staged);
|
||||
}
|
||||
staged.set(artifactKey(artifact), artifact);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
commit(artifact) {
|
||||
if (artifact?.kind !== ARTIFACT_KIND || !artifactStorage.has(artifact)) return null;
|
||||
const keyForTurn = turnKey(artifact.origin.sessionId, artifact.origin.turn);
|
||||
const key = artifactKey(artifact);
|
||||
const staged = this.#stagedTurns.get(keyForTurn);
|
||||
if (staged?.get(key) !== artifact) return null;
|
||||
staged.delete(key);
|
||||
if (staged.size === 0) this.#stagedTurns.delete(keyForTurn);
|
||||
let committed = this.#turns.get(keyForTurn);
|
||||
if (!committed) {
|
||||
committed = new Map();
|
||||
this.#turns.set(keyForTurn, committed);
|
||||
}
|
||||
committed.set(key, artifact);
|
||||
return publicArtifact(artifact);
|
||||
}
|
||||
|
||||
release(artifact) {
|
||||
if (artifact?.kind !== ARTIFACT_KIND) return;
|
||||
const keyForTurn = turnKey(artifact.origin.sessionId, artifact.origin.turn);
|
||||
const key = artifactKey(artifact);
|
||||
const staged = this.#stagedTurns.get(keyForTurn);
|
||||
if (staged?.get(key) === artifact) staged.delete(key);
|
||||
if (staged?.size === 0) this.#stagedTurns.delete(keyForTurn);
|
||||
cleanupArtifactStorage(artifact);
|
||||
}
|
||||
|
||||
take(sessionId, turn, { signal } = {}) {
|
||||
const keyForTurn = turnKey(sessionId, turn);
|
||||
const committed = this.#turns.get(keyForTurn);
|
||||
this.#turns.delete(keyForTurn);
|
||||
if (!committed) return [];
|
||||
if (signal?.aborted) {
|
||||
for (const artifact of committed.values()) cleanupArtifactStorage(artifact);
|
||||
return [];
|
||||
}
|
||||
const claimed = this.#claimedTurns.get(keyForTurn) ?? new Map();
|
||||
this.#claimedTurns.set(keyForTurn, claimed);
|
||||
const artifacts = [];
|
||||
for (const [key, artifact] of committed) {
|
||||
if (!artifactStorage.has(artifact)) continue;
|
||||
claimed.set(key, artifact);
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
if (claimed.size === 0) this.#claimedTurns.delete(keyForTurn);
|
||||
else if (!this.#bindClaimSignal(keyForTurn, signal)) return [];
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
discard(sessionId, turn) {
|
||||
if (typeof sessionId !== 'string' || !Number.isInteger(turn)) return;
|
||||
const keyForTurn = turnKey(sessionId, turn);
|
||||
const artifacts = new Set([
|
||||
...this.#turns.get(keyForTurn)?.values() ?? [],
|
||||
...this.#stagedTurns.get(keyForTurn)?.values() ?? [],
|
||||
]);
|
||||
this.#turns.delete(keyForTurn);
|
||||
this.#stagedTurns.delete(keyForTurn);
|
||||
for (const artifact of artifacts) cleanupArtifactStorage(artifact);
|
||||
}
|
||||
|
||||
clear() {
|
||||
const artifacts = new Set();
|
||||
for (const entries of this.#turns.values()) {
|
||||
for (const artifact of entries.values()) artifacts.add(artifact);
|
||||
}
|
||||
for (const entries of this.#stagedTurns.values()) {
|
||||
for (const artifact of entries.values()) artifacts.add(artifact);
|
||||
}
|
||||
for (const entries of this.#claimedTurns.values()) {
|
||||
for (const artifact of entries.values()) artifacts.add(artifact);
|
||||
}
|
||||
for (const artifact of artifacts) cleanupArtifactStorage(artifact);
|
||||
this.#turns.clear();
|
||||
this.#stagedTurns.clear();
|
||||
this.#claimedTurns.clear();
|
||||
for (const turnKey of this.#claimSignals.keys()) this.#releaseClaimSignal(turnKey);
|
||||
for (const consumer of this.#consumersByPrompt.values()) consumer.released = true;
|
||||
this.#consumersByPrompt.clear();
|
||||
this.#consumersByTurn.clear();
|
||||
this.#openTurns.clear();
|
||||
}
|
||||
|
||||
#bindClaimSignal(turnKey, signal) {
|
||||
if (!signal) return true;
|
||||
this.#releaseClaimSignal(turnKey);
|
||||
let claim = this.#signalClaims.get(signal);
|
||||
if (!claim) {
|
||||
claim = { turnKeys: new Set(), onAbort: null };
|
||||
claim.onAbort = () => {
|
||||
for (const claimedTurnKey of [...claim.turnKeys]) {
|
||||
for (const artifact of this.#claimedTurns.get(claimedTurnKey)?.values() ?? []) {
|
||||
cleanupArtifactStorage(artifact);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.#signalClaims.set(signal, claim);
|
||||
signal.addEventListener('abort', claim.onAbort, { once: true });
|
||||
}
|
||||
claim.turnKeys.add(turnKey);
|
||||
this.#claimSignals.set(turnKey, signal);
|
||||
if (signal.aborted) {
|
||||
claim.onAbort();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#releaseClaimSignal(turnKey) {
|
||||
const signal = this.#claimSignals.get(turnKey);
|
||||
if (!signal) return;
|
||||
this.#claimSignals.delete(turnKey);
|
||||
const claim = this.#signalClaims.get(signal);
|
||||
claim?.turnKeys.delete(turnKey);
|
||||
if (claim?.turnKeys.size === 0) {
|
||||
signal.removeEventListener('abort', claim.onAbort);
|
||||
this.#signalClaims.delete(signal);
|
||||
}
|
||||
}
|
||||
|
||||
#forgetArtifact(artifact) {
|
||||
const keyForTurn = turnKey(artifact.origin.sessionId, artifact.origin.turn);
|
||||
const key = artifactKey(artifact);
|
||||
const committed = this.#turns.get(keyForTurn);
|
||||
if (committed?.get(key) === artifact) committed.delete(key);
|
||||
if (committed?.size === 0) this.#turns.delete(keyForTurn);
|
||||
const staged = this.#stagedTurns.get(keyForTurn);
|
||||
if (staged?.get(key) === artifact) staged.delete(key);
|
||||
if (staged?.size === 0) this.#stagedTurns.delete(keyForTurn);
|
||||
const claimed = this.#claimedTurns.get(keyForTurn);
|
||||
if (claimed?.get(key) === artifact) claimed.delete(key);
|
||||
if (claimed?.size === 0) {
|
||||
this.#claimedTurns.delete(keyForTurn);
|
||||
this.#releaseClaimSignal(keyForTurn);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const outboundArtifactRegistry = new OutboundArtifactRegistry();
|
||||
|
||||
/**
|
||||
* Build a two-phase tool: execute stages a file; the authoritative tools/result
|
||||
* observer commits only a successful native call or successful Code Mode parent.
|
||||
*/
|
||||
export function createOutboundArtifactTool({ registry = outboundArtifactRegistry } = {}) {
|
||||
const staged = new WeakMap();
|
||||
const pendingByParent = new Map();
|
||||
const appendPending = (parent, artifact) => {
|
||||
let pending = pendingByParent.get(parent);
|
||||
if (!pending) {
|
||||
pending = { artifacts: [] };
|
||||
pendingByParent.set(parent, pending);
|
||||
}
|
||||
pending.artifacts.push(artifact);
|
||||
};
|
||||
const definition = Object.freeze({
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
description: 'Send a readable file to the user through the current conversation. Existing and newly created files are both valid.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'Absolute path, or a path relative to the current workspace.',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
artifactId: { type: 'string' },
|
||||
fileName: { type: 'string' },
|
||||
size: { type: 'number' },
|
||||
},
|
||||
required: ['artifactId', 'fileName', 'size'],
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: `Registered ${value.fileName} (${value.size} bytes) for IM delivery.`,
|
||||
}],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const artifact = await registry.stage(args, exec);
|
||||
staged.set(exec, artifact);
|
||||
return publicArtifact(artifact);
|
||||
},
|
||||
});
|
||||
|
||||
const onResult = (exec, result) => {
|
||||
if (exec?.name === OUTBOUND_ARTIFACT_TOOL) {
|
||||
const artifact = staged.get(exec);
|
||||
if (!artifact) return;
|
||||
staged.delete(exec);
|
||||
if (result?.isError) {
|
||||
registry.release(artifact);
|
||||
return;
|
||||
}
|
||||
if (exec.parent === undefined) registry.commit(artifact);
|
||||
else appendPending(exec.parent, artifact);
|
||||
return;
|
||||
}
|
||||
const pending = pendingByParent.get(exec?.token);
|
||||
if (!pending) return;
|
||||
pendingByParent.delete(exec.token);
|
||||
if (result?.isError) {
|
||||
for (const artifact of pending.artifacts) registry.release(artifact);
|
||||
return;
|
||||
}
|
||||
if (exec.parent !== undefined) {
|
||||
for (const artifact of pending.artifacts) appendPending(exec.parent, artifact);
|
||||
return;
|
||||
}
|
||||
for (const artifact of pending.artifacts) registry.commit(artifact);
|
||||
};
|
||||
|
||||
return Object.freeze({ definition, onResult });
|
||||
}
|
||||
|
||||
/** Register the file-return tool without a per-request Gate. */
|
||||
export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRegistry } = {}) {
|
||||
if (typeof ctx?.tools?.register !== 'function'
|
||||
|| typeof ctx?.systemPrompt?.section !== 'function'
|
||||
|| typeof ctx?.on !== 'function') return false;
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
ctx.tools.register(tool.definition);
|
||||
ctx.on('tools/result', tool.onResult);
|
||||
ctx.on('session/event', (session, event) => registry.observeSessionEvent(session, event));
|
||||
ctx.on('session/disposed', (session) => registry.disposeSession(session));
|
||||
ctx.systemPrompt.section({
|
||||
name: 'dsh-im:return-file',
|
||||
order: 115,
|
||||
text: `When the user asks to receive a file, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Materialize the registered snapshot for the channel provider. */
|
||||
export async function materializeOutboundArtifact(artifact, {
|
||||
signal,
|
||||
} = {}) {
|
||||
if (signal?.aborted) {
|
||||
cleanupArtifactStorage(artifact);
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
if (artifact?.kind !== ARTIFACT_KIND || artifact.schemaVersion !== 1
|
||||
|| !artifactStorage.has(artifact)
|
||||
|| typeof artifact.digest !== 'string'
|
||||
|| !Number.isSafeInteger(artifact.size) || artifact.size < 0) {
|
||||
throw artifactError('artifact-invalid', 'The file registration is invalid.');
|
||||
}
|
||||
const storage = artifactStorage.get(artifact);
|
||||
storage.materializing += 1;
|
||||
let handle;
|
||||
let materialized = false;
|
||||
try {
|
||||
const noFollow = Number.isInteger(fsConstants.O_NOFOLLOW) ? fsConstants.O_NOFOLLOW : 0;
|
||||
handle = await open(storage.path, fsConstants.O_RDONLY | noFollow);
|
||||
const before = await handle.stat({ bigint: true });
|
||||
if (!before.isFile() || before.size !== BigInt(artifact.size)) {
|
||||
throw artifactError('artifact-invalid', 'The file registration is invalid.');
|
||||
}
|
||||
const bytes = await readExactArtifactFile(handle, artifact.size, {
|
||||
signal,
|
||||
errorCode: 'artifact-invalid',
|
||||
errorMessage: 'The file registration is invalid.',
|
||||
});
|
||||
const after = await handle.stat({ bigint: true });
|
||||
if (!sameIdentity(before, after)
|
||||
|| bytes.byteLength !== artifact.size
|
||||
|| sha256(bytes) !== artifact.digest) {
|
||||
throw artifactError('artifact-invalid', 'The file registration is invalid.');
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
materialized = true;
|
||||
const file = Object.freeze({
|
||||
artifactId: artifact.artifactId,
|
||||
deliveryKey: artifact.deliveryKey,
|
||||
fileName: artifact.fileName,
|
||||
mediaType: artifact.mediaType,
|
||||
size: artifact.size,
|
||||
bytes,
|
||||
});
|
||||
materializedArtifactSources.set(file, artifact);
|
||||
storage.materialized = true;
|
||||
return file;
|
||||
} catch (error) {
|
||||
if (error?.code?.startsWith?.('artifact-')) throw error;
|
||||
if (signal?.aborted) throw signal.reason ?? error;
|
||||
throw artifactError('artifact-invalid', 'The file registration is invalid.');
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
storage.materializing -= 1;
|
||||
if (!materialized || storage.cleanupRequested) {
|
||||
cleanupArtifactStorage(artifact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep a materialized snapshot alive until an unabortable provider call settles. */
|
||||
export function trackOutboundArtifactProviderPromise(file, promise) {
|
||||
const artifact = materializedArtifactSources.get(file);
|
||||
if (!artifact || !artifactStorage.has(artifact)
|
||||
|| !promise || typeof promise.then !== 'function') return promise;
|
||||
const settlements = artifactProviderSettlements.get(artifact) ?? new Set();
|
||||
const settlement = Promise.resolve(promise).then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
settlements.add(settlement);
|
||||
artifactProviderSettlements.set(artifact, settlements);
|
||||
void settlement.finally(() => {
|
||||
settlements.delete(settlement);
|
||||
if (settlements.size === 0) artifactProviderSettlements.delete(artifact);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Release a claimed snapshot after its provider send reaches a terminal result. */
|
||||
export function releaseOutboundArtifact(artifact) {
|
||||
const storage = artifactStorage.get(artifact);
|
||||
if (storage) storage.releaseRequested = true;
|
||||
cleanupArtifactStorage(artifact);
|
||||
}
|
||||
153
src/channels/shared/semantic/delivery.mjs
Normal file
153
src/channels/shared/semantic/delivery.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
export const DELIVERY_RECEIPT_SCHEMA_VERSION = 1;
|
||||
|
||||
const ARTIFACT_OUTCOMES = new Set(['sent', 'rejected', 'failed', 'unknown']);
|
||||
const REJECTED_ARTIFACT_ERRORS = new Set([
|
||||
'artifact-changed',
|
||||
'artifact-context-required',
|
||||
'artifact-empty',
|
||||
'artifact-invalid',
|
||||
'artifact-not-file',
|
||||
'artifact-permission-required',
|
||||
'artifact-provider-rejected',
|
||||
'artifact-too-large',
|
||||
'artifact-unavailable',
|
||||
]);
|
||||
|
||||
export function providerMessageIdsFor(value) {
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
const ids = Array.isArray(value.providerMessageIds)
|
||||
? value.providerMessageIds
|
||||
.filter((candidate) => (
|
||||
(typeof candidate === 'string' && candidate.trim())
|
||||
|| Number.isSafeInteger(candidate)
|
||||
))
|
||||
.map(String)
|
||||
: [];
|
||||
const candidates = [
|
||||
value.message_id,
|
||||
value.messageId,
|
||||
value.id,
|
||||
value.ts,
|
||||
value.message?.message_id,
|
||||
value.message?.messageId,
|
||||
value.message?.id,
|
||||
value.message?.ts,
|
||||
value.key?.id,
|
||||
value.data?.message_id,
|
||||
];
|
||||
const id = candidates.find((candidate) => (
|
||||
(typeof candidate === 'string' && candidate.trim())
|
||||
|| Number.isSafeInteger(candidate)
|
||||
));
|
||||
if (id !== undefined) ids.push(String(id));
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
function requiredString(value, name) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new TypeError(`${name} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function providerIds(values) {
|
||||
if (!Array.isArray(values)) throw new TypeError('providerMessageIds must be an array');
|
||||
const ids = [];
|
||||
const seen = new Set();
|
||||
for (const value of values) {
|
||||
const id = requiredString(value, 'providerMessageId');
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
return Object.freeze(ids);
|
||||
}
|
||||
|
||||
function artifactResults(values) {
|
||||
if (!Array.isArray(values)) throw new TypeError('artifacts must be an array');
|
||||
return Object.freeze(values.map((value) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new TypeError('artifact result must be an object');
|
||||
}
|
||||
const artifactId = requiredString(value.artifactId, 'artifactId');
|
||||
if (!ARTIFACT_OUTCOMES.has(value.outcome)) {
|
||||
throw new TypeError('artifact outcome must be sent, rejected, failed, or unknown');
|
||||
}
|
||||
const reason = value.reason === undefined
|
||||
? undefined
|
||||
: requiredString(value.reason, 'artifact reason');
|
||||
return Object.freeze({
|
||||
artifactId,
|
||||
outcome: value.outcome,
|
||||
...(reason === undefined ? {} : { reason }),
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
export function artifactOutcomeForError(error) {
|
||||
const code = typeof error === 'string' ? error : error?.code;
|
||||
if (code === 'artifact-delivery-uncertain') return 'unknown';
|
||||
if (REJECTED_ARTIFACT_ERRORS.has(code)) return 'rejected';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
export function createDeliveryReceipt({
|
||||
deliveryId,
|
||||
presentation,
|
||||
providerMessageIds = [],
|
||||
artifacts = [],
|
||||
}) {
|
||||
return Object.freeze({
|
||||
schemaVersion: DELIVERY_RECEIPT_SCHEMA_VERSION,
|
||||
deliveryId: requiredString(deliveryId, 'deliveryId'),
|
||||
presentation: requiredString(presentation, 'presentation'),
|
||||
providerMessageIds: providerIds(providerMessageIds),
|
||||
artifacts: artifactResults(artifacts),
|
||||
});
|
||||
}
|
||||
|
||||
export function createArtifactFailureReceipt({
|
||||
artifactId,
|
||||
deliveryId,
|
||||
error,
|
||||
presentation = 'text-fallback',
|
||||
providerMessageIds = [],
|
||||
}) {
|
||||
const code = typeof error === 'string' ? error : error?.code;
|
||||
const reason = typeof code === 'string' && code
|
||||
? code
|
||||
: 'artifact-provider-failed';
|
||||
return createDeliveryReceipt({
|
||||
deliveryId,
|
||||
presentation,
|
||||
providerMessageIds,
|
||||
artifacts: [{
|
||||
artifactId,
|
||||
outcome: artifactOutcomeForError(error),
|
||||
reason,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeDeliveryReceipts({ deliveryId, presentation, receipts }) {
|
||||
if (!Array.isArray(receipts) || receipts.length === 0) {
|
||||
throw new TypeError('receipts must contain at least one delivery receipt');
|
||||
}
|
||||
const messageIds = [];
|
||||
const artifacts = new Map();
|
||||
for (const receipt of receipts) {
|
||||
if (!receipt || receipt.schemaVersion !== DELIVERY_RECEIPT_SCHEMA_VERSION) {
|
||||
throw new TypeError('receipt must use DeliveryReceipt schema version 1');
|
||||
}
|
||||
messageIds.push(...(receipt.providerMessageIds ?? []));
|
||||
for (const artifact of receipt.artifacts ?? []) {
|
||||
artifacts.set(artifact.artifactId, artifact);
|
||||
}
|
||||
}
|
||||
return createDeliveryReceipt({
|
||||
deliveryId,
|
||||
presentation,
|
||||
providerMessageIds: messageIds,
|
||||
artifacts: [...artifacts.values()],
|
||||
});
|
||||
}
|
||||
|
|
@ -28,8 +28,19 @@ import {
|
|||
harnessQuestionText,
|
||||
validHarnessQuestion,
|
||||
} from './harness-question.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from './semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from './semantic/delivery.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
|
||||
|
||||
function cleanText(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
|
|
@ -42,6 +53,42 @@ function canClaimInteractionReply(message, pending, senderId) {
|
|||
&& Boolean(cleanText(message.content));
|
||||
}
|
||||
|
||||
function artifactFailureText(fileName, error, descriptor) {
|
||||
const name = String(fileName ?? '结果文件')
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 255) || '结果文件';
|
||||
switch (error?.code) {
|
||||
case 'artifact-delivery-uncertain':
|
||||
return `结果文件「${name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
|
||||
case 'artifact-permission-required':
|
||||
if (descriptor?.key === 'slack') {
|
||||
return `结果文件「${name}」已生成,但 Slack 应用缺少 files:write 权限。请更新 Manifest、重新安装应用并重新连接机器人后重试。`;
|
||||
}
|
||||
if (descriptor?.key === 'discord') {
|
||||
return `结果文件「${name}」已生成,但机器人缺少 Discord 的 Send Messages、Attach Files 或 Read Message History 权限。`;
|
||||
}
|
||||
if (descriptor?.key === 'telegram') {
|
||||
return `结果文件「${name}」已生成,但 Telegram 不允许机器人在当前聊天发送文档,请检查聊天权限。`;
|
||||
}
|
||||
return `结果文件「${name}」已生成,但当前机器人没有文件发送权限,请检查渠道权限。`;
|
||||
case 'artifact-too-large':
|
||||
return `结果文件「${name}」超过当前渠道大小上限,未发送。`;
|
||||
case 'artifact-empty':
|
||||
return `结果文件「${name}」为空,未发送。`;
|
||||
case 'artifact-invalid':
|
||||
case 'artifact-changed':
|
||||
case 'artifact-unavailable':
|
||||
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
|
||||
case 'artifact-rate-limited':
|
||||
return `结果文件「${name}」暂时被当前渠道限流,未能发送,请稍后重试。`;
|
||||
case 'artifact-provider-rejected':
|
||||
return `结果文件「${name}」已生成,但当前渠道拒绝了该文件或文件消息。`;
|
||||
default:
|
||||
return `结果文件「${name}」已生成,但当前渠道暂时未能发送,请稍后重试。`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTextBridgeStatus() {
|
||||
return {
|
||||
messagesReceived: 0,
|
||||
|
|
@ -285,6 +332,76 @@ export class TextHarnessBridge {
|
|||
});
|
||||
}
|
||||
|
||||
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let userVisible = Boolean(baseReceipt);
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#bot.sendFile !== 'function') {
|
||||
const unavailable = new Error('Native file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
this.#signal?.throwIfAborted();
|
||||
const result = await this.#bot.sendFile(target, file);
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: `${this.#descriptor.key}-file`,
|
||||
providerMessageIds: providerMessageIdsFor(result),
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
userVisible = true;
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let providerMessageIds = [];
|
||||
let noticeSent = false;
|
||||
try {
|
||||
const notice = await this.#bot.sendText(
|
||||
target,
|
||||
artifactFailureText(artifact?.fileName, error, this.#descriptor),
|
||||
);
|
||||
providerMessageIds = providerMessageIdsFor(notice);
|
||||
noticeSent = true;
|
||||
} catch {
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] unable to send the safe result-file failure notice`,
|
||||
);
|
||||
}
|
||||
const failureReceipt = createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
});
|
||||
receipts.push(failureReceipt);
|
||||
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
const receipt = receipts.length === 0
|
||||
? null
|
||||
: receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt
|
||||
? `${this.#descriptor.key}-text-and-files`
|
||||
: `${this.#descriptor.key}-files`,
|
||||
receipts,
|
||||
});
|
||||
return { receipt, userVisible };
|
||||
}
|
||||
|
||||
async #process(message, messageId, senderId, conversationKey, {
|
||||
alreadyRecorded = false,
|
||||
} = {}) {
|
||||
|
|
@ -386,7 +503,7 @@ export class TextHarnessBridge {
|
|||
const content = hasImages
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key: conversationKey,
|
||||
|
|
@ -412,10 +529,23 @@ export class TextHarnessBridge {
|
|||
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
||||
},
|
||||
});
|
||||
const visibleAnswer = !cleanText(answer) && artifacts.length > 0
|
||||
? FILE_ONLY_COMPLETION_TEXT
|
||||
: answer;
|
||||
let textDeliveryError = null;
|
||||
let textReceipt = null;
|
||||
if (stream) {
|
||||
try {
|
||||
await stream.finish(answer);
|
||||
const result = await stream.finish(visibleAnswer);
|
||||
streamFinished = true;
|
||||
textReceipt = createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: `${this.#descriptor.key}-stream`,
|
||||
providerMessageIds: [
|
||||
...providerMessageIdsFor(stream),
|
||||
...providerMessageIdsFor(result),
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
stream.cancel?.();
|
||||
this.#logger.warn?.(
|
||||
|
|
@ -424,10 +554,26 @@ export class TextHarnessBridge {
|
|||
);
|
||||
}
|
||||
}
|
||||
if (!streamFinished) await this.#bot.sendText(target, answer);
|
||||
if (!streamFinished) {
|
||||
try {
|
||||
const result = await this.#bot.sendText(target, visibleAnswer);
|
||||
textReceipt = createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: `${this.#descriptor.key}-text`,
|
||||
providerMessageIds: providerMessageIdsFor(result),
|
||||
});
|
||||
} catch (error) {
|
||||
textDeliveryError = error;
|
||||
}
|
||||
}
|
||||
// A failed final text must not discard an already registered result file.
|
||||
// Settle the independent attachment path before surfacing the text error.
|
||||
const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
|
||||
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
if (stream) {
|
||||
|
|
|
|||
|
|
@ -59,9 +59,23 @@ export async function askInWorkspaceSession({
|
|||
return { sessionId, session };
|
||||
});
|
||||
if (!binding) continue;
|
||||
const artifacts = [];
|
||||
const originalOnArtifact = typeof askOptions === 'object'
|
||||
&& typeof askOptions?.onArtifact === 'function'
|
||||
? askOptions.onArtifact
|
||||
: null;
|
||||
const artifactOptions = typeof askOptions === 'number'
|
||||
? { timeoutMs: askOptions }
|
||||
: { ...askOptions };
|
||||
artifactOptions.onArtifact = async (artifact) => {
|
||||
artifacts.push(artifact);
|
||||
await originalOnArtifact?.(artifact);
|
||||
};
|
||||
const answer = await binding.session.ask(content ?? text, artifactOptions);
|
||||
return {
|
||||
sessionId: binding.sessionId,
|
||||
answer: await binding.session.ask(content ?? text, askOptions),
|
||||
answer,
|
||||
...(artifacts.length > 0 ? { artifacts } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue