feat: add native result-file delivery

This commit is contained in:
xmanrui 2026-08-23 20:02:06 +08:00
parent 2803bbcbab
commit d472dd5a75
65 changed files with 10205 additions and 255 deletions

View file

@ -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;

View file

@ -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);
}

View 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);
}

View 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()],
});
}

View file

@ -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) {

View file

@ -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;