Merge remote-tracking branch 'upstream/main'

This commit is contained in:
C3H3-AI 2026-08-18 19:46:22 +08:00
commit 55ed947d9a
22 changed files with 2976 additions and 170 deletions

View file

@ -0,0 +1,472 @@
const APPROVAL_REPLIES = new Map([
['批准', 'allowed-once'],
['同意', 'allowed-once'],
['yes', 'allowed-once'],
['拒绝', 'rejected'],
['不同意', 'rejected'],
['no', 'rejected'],
]);
const APPROVAL_PROMPT = '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。';
const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。';
const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。';
const RESOLVED_ROUTE_TTL_MS = 5 * 60_000;
const MAX_RESOLVED_ROUTES = 2_048;
function cleanText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function printableText(value) {
return cleanText(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
}
export function harnessApprovalDecision(text) {
return APPROVAL_REPLIES.get(cleanText(text).toLowerCase()) ?? null;
}
export function validHarnessApproval(payload) {
return payload?.type === 'approval/requested'
&& Boolean(cleanText(payload.sessionId))
&& Boolean(cleanText(payload.approvalId))
&& Boolean(cleanText(payload.toolName))
&& (payload.callId === undefined || Boolean(cleanText(payload.callId)))
&& (payload.reason === undefined || typeof payload.reason === 'string');
}
function toolArguments(toolCall) {
const source = toolCall?.arguments;
if (source !== null && typeof source === 'object') {
try {
return JSON.stringify(source, null, 2);
} catch {
return null;
}
}
if (typeof source !== 'string') return null;
const raw = printableText(source);
// Harness treats an empty tool argument string as an empty object.
if (!raw) return source === '' ? '{}' : null;
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
export function harnessApprovalText(payload, {
toolCall,
requiresMention = false,
maxArgumentsLength = 6_000,
} = {}) {
if (!validHarnessApproval(payload)) return null;
const callId = cleanText(payload.callId);
if (!callId
|| cleanText(toolCall?.callId) !== callId
|| cleanText(toolCall?.name) !== cleanText(payload.toolName)) return null;
const operation = toolArguments(toolCall);
if (!operation || operation.length > maxArgumentsLength) return null;
const lines = [
'DeepSeek Harness 需要你的审批:',
'',
`工具:${printableText(payload.toolName)}`,
'操作参数:',
operation,
];
const reason = printableText(payload.reason);
if (reason) lines.push(`原因:${reason}`);
lines.push('', APPROVAL_PROMPT);
if (requiresMention) lines.push('', '群聊中请 @机器人 后发送审批决定。');
return lines.join('\n');
}
function approvalResult(pending, outcome) {
return {
ok: true,
value: {
sessionId: pending.sessionId,
approvalId: pending.approvalId,
outcome,
},
};
}
function approvalOutcomeText(outcome) {
if (outcome === 'allowed-once') return '已批准,仅对本次操作有效。';
if (outcome === 'rejected') return '已拒绝此次操作。';
return APPROVAL_RESOLVED_TEXT;
}
export class HarnessApprovalQueue {
#label;
#logger;
#byId = new Map();
#routes = new Map();
#resolvedRoutes = new Map();
constructor({ label = 'IM', logger = console } = {}) {
this.#label = label;
this.#logger = logger;
}
claimReply({
key,
actor,
text,
addressed = true,
hasPendingQuestion = false,
questionCompletion,
isQuestionPending,
send,
}) {
const route = this.#routes.get(key);
const pending = route?.items[0];
const decision = harnessApprovalDecision(text);
const notice = (value, resolved = false) => ({
...(resolved ? { resolved: true } : {}),
process: async (before) => {
if (typeof before === 'function' && await before() === false) return;
await send(value);
},
});
// Match Harness' own interaction precedence: a live ask_user_question
// outranks sibling approvals. Otherwise a question answer such as "yes"
// could accidentally authorize a tool call.
const deferredByQuestion = hasPendingQuestion
&& pending
&& questionCompletion
&& typeof questionCompletion.then === 'function';
if (hasPendingQuestion && !deferredByQuestion) return null;
if (!pending || pending.inactive) {
const resolvedUntil = this.#resolvedRoutes.get(key) ?? 0;
if (resolvedUntil <= Date.now()) {
this.#resolvedRoutes.delete(key);
return null;
}
if (!decision) return null;
return notice(APPROVAL_RESOLVED_TEXT, true);
}
if (pending.actor !== actor || (pending.requiresMention && addressed !== true)) {
if (!decision) return null;
return notice('只有发起当前任务的用户可以处理这条审批。');
}
return {
process: async (before) => {
const presentedWhenClaimed = pending.presented;
const previous = pending.replyTail ?? Promise.resolve();
const task = previous
.catch(() => undefined)
.then(async () => {
if (typeof before === 'function' && await before() === false) return;
if (deferredByQuestion) {
await questionCompletion.catch(() => undefined);
if (pending.inactive || pending.resolving) return;
if (typeof isQuestionPending === 'function' && isQuestionPending()) {
await send(APPROVAL_AFTER_QUESTION_PROMPT);
return;
}
}
await pending.activationTask?.catch(() => undefined);
await pending.presentationTask?.catch(() => undefined);
if (pending.inactive || pending.resolving) {
await send(APPROVAL_RESOLVED_TEXT);
return;
}
pending.send = send;
// Never turn a decision sent before the operation was visibly
// presented into an approval. This also covers a failed presentation
// and the small FIFO promotion window before the next item is shown.
if (!presentedWhenClaimed || !pending.presented) {
if (!pending.presented) await this.#present(pending);
if (pending.inactive || pending.resolving) return;
await send(APPROVAL_PROMPT);
return;
}
if (pending.submitting) {
await send('审批决定正在提交,请稍候。');
return;
}
if (!decision) {
await send(APPROVAL_PROMPT);
return;
}
await this.#submit(pending, decision);
});
pending.replyTail = task;
try {
await task;
} finally {
if (pending.replyTail === task) pending.replyTail = null;
}
},
};
}
async handleRequested(interaction, context) {
if (interaction?.kind !== 'approval') return false;
const payload = interaction.payload;
const approvalId = cleanText(payload?.approvalId);
if (!cleanText(interaction.rpcId)
|| !cleanText(interaction.sessionId)
|| !approvalId
|| !validHarnessApproval(payload)
|| payload.sessionId !== interaction.sessionId
|| typeof interaction.respond !== 'function') {
this.#logger.warn?.(`[dsh-im:${this.#label}] ignored an invalid Harness approval`);
return true;
}
if (interaction.recovered === true) {
await this.#rejectInteraction(interaction, payload);
return true;
}
const existing = this.#byId.get(approvalId);
if (existing) {
existing.interaction = interaction;
existing.toolCall = interaction.toolCall;
if (!existing.presented) await this.#present(existing);
return true;
}
const send = context?.send;
const key = cleanText(context?.key);
const actor = cleanText(context?.actor);
if (!key || !actor || typeof send !== 'function') {
this.#logger.warn?.(`[dsh-im:${this.#label}] ignored an approval without a reply route`);
await this.#rejectInteraction(interaction, payload);
return true;
}
const text = harnessApprovalText(payload, {
toolCall: interaction.toolCall,
requiresMention: context.requiresMention === true,
});
if (!text) {
const rejected = await this.#rejectInteraction(interaction, payload);
await send(rejected
? '无法完整展示这次操作,已安全拒绝此次审批。'
: APPROVAL_RESOLVED_TEXT);
return true;
}
const pending = {
approvalId,
sessionId: interaction.sessionId,
interaction,
toolCall: interaction.toolCall,
key,
actor,
requiresMention: context.requiresMention === true,
send,
text,
presented: false,
presentationTask: null,
deliveryCompleted: false,
replyTail: null,
submitting: false,
inactive: false,
resolving: false,
closedOutcome: null,
resolutionNotified: false,
activationTask: null,
};
this.#byId.set(approvalId, pending);
const route = this.#routes.get(key) ?? { items: [] };
route.items.push(pending);
this.#routes.set(key, route);
if (route.items[0] === pending) await this.#present(pending);
return true;
}
async handleResolved(resolution) {
if (resolution?.kind !== 'approval') return false;
const pending = this.#byId.get(cleanText(resolution.interactionId));
if (!pending) return true;
// A queued item may already be the next route head while the previous
// item's confirmation is still in flight. Preserve that route barrier so
// resolving this item cannot expose a later approval out of order.
pending.resolving = true;
if (pending.activationTask) {
await pending.activationTask.catch(() => undefined);
}
if (pending.inactive || this.#byId.get(pending.approvalId) !== pending) return true;
const presentationTask = pending.presentationTask;
const shouldNotify = pending.presented || presentationTask;
const send = pending.send;
const next = this.#remove(pending);
await this.#transition(next, async () => {
let delivered = pending.presented;
if (presentationTask) {
delivered = await presentationTask.then(() => true, () => false);
}
if (shouldNotify && delivered) {
pending.resolutionNotified = true;
await send(approvalOutcomeText(resolution.outcome)).catch(() => undefined);
}
});
return true;
}
async closeRoute(key) {
const route = this.#routes.get(key);
if (!route) return;
const pendingItems = [...route.items];
for (const pending of pendingItems) this.#remove(pending);
await Promise.all(pendingItems.map(async (pending) => {
try {
await pending.interaction.respond(
approvalResult(pending, 'rejected'),
{ signal: AbortSignal.timeout(5_000) },
);
pending.closedOutcome = 'rejected';
if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
pending.resolutionNotified = true;
await pending.send(approvalOutcomeText('rejected')).catch(() => undefined);
}
} catch (error) {
if (error?.code === 'interaction-not-pending') {
pending.closedOutcome = 'resolved';
if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
pending.resolutionNotified = true;
await pending.send(APPROVAL_RESOLVED_TEXT).catch(() => undefined);
}
} else {
this.#logger.warn?.(`[dsh-im:${this.#label}] failed to reject a closing approval:`, error);
}
}
}));
}
async #present(pending) {
if (this.#routes.get(pending.key)?.items[0] !== pending
|| pending.inactive || pending.resolving || pending.presented) return;
await pending.activationTask?.catch(() => undefined);
if (this.#routes.get(pending.key)?.items[0] !== pending
|| pending.inactive || pending.resolving || pending.presented) return;
if (pending.presentationTask) return pending.presentationTask;
const task = Promise.resolve().then(() => pending.send(pending.text));
pending.presentationTask = task;
try {
await task;
pending.deliveryCompleted = true;
if (!pending.inactive) {
pending.presented = true;
} else if (pending.closedOutcome && !pending.resolutionNotified) {
pending.resolutionNotified = true;
await pending.send(approvalOutcomeText(pending.closedOutcome)).catch(() => undefined);
}
} finally {
if (pending.presentationTask === task) pending.presentationTask = null;
}
}
async #submit(pending, outcome) {
pending.submitting = true;
try {
await pending.interaction.respond(approvalResult(pending, outcome));
} catch (error) {
if (error?.code === 'interaction-not-pending') {
const send = pending.send;
const next = this.#remove(pending);
await this.#transition(next, async () => {
if (!pending.resolutionNotified) {
await send(APPROVAL_RESOLVED_TEXT).catch(() => undefined);
}
});
return;
}
if (pending.inactive) return;
pending.submitting = false;
this.#logger.error?.(`[dsh-im:${this.#label}] failed to submit an approval:`, error);
await pending.send('审批提交失败,请重新回复「批准」或「拒绝」。').catch(() => undefined);
return;
}
const send = pending.send;
const next = this.#remove(pending);
await this.#transition(next, async () => {
if (!pending.resolutionNotified) {
await send(approvalOutcomeText(outcome)).catch(() => undefined);
}
});
}
async #transition(next, work) {
let release;
const barrier = new Promise((resolve) => { release = resolve; });
if (next) next.activationTask = barrier;
try {
await work();
} finally {
release();
if (next?.activationTask === barrier) next.activationTask = null;
}
await this.#promote(next);
}
async #promote(pending) {
if (!pending) return;
try {
await this.#present(pending);
} catch (error) {
this.#logger.error?.(
`[dsh-im:${this.#label}] failed to present the next approval:`,
error,
);
try {
pending.interaction.reconnect?.();
} catch {
// A replay will retry presentation when the transport can reconnect.
}
}
}
#remove(pending) {
if (pending.inactive) return null;
pending.inactive = true;
this.#rememberResolvedRoute(pending.key);
this.#byId.delete(pending.approvalId);
const route = this.#routes.get(pending.key);
if (!route) return null;
const wasCurrent = route.items[0] === pending;
const index = route.items.indexOf(pending);
if (index !== -1) route.items.splice(index, 1);
if (route.items.length === 0) {
this.#routes.delete(pending.key);
return null;
}
return wasCurrent ? route.items[0] : null;
}
#rememberResolvedRoute(key) {
const now = Date.now();
for (const [routeKey, expiresAt] of this.#resolvedRoutes) {
if (expiresAt <= now) this.#resolvedRoutes.delete(routeKey);
}
this.#resolvedRoutes.delete(key);
this.#resolvedRoutes.set(key, now + RESOLVED_ROUTE_TTL_MS);
while (this.#resolvedRoutes.size > MAX_RESOLVED_ROUTES) {
this.#resolvedRoutes.delete(this.#resolvedRoutes.keys().next().value);
}
}
async #rejectInteraction(interaction, payload) {
try {
await interaction.respond({
ok: true,
value: {
sessionId: interaction.sessionId,
approvalId: payload.approvalId,
outcome: 'rejected',
},
}, { signal: AbortSignal.timeout(5_000) });
return true;
} catch (error) {
if (error?.code === 'interaction-not-pending') return false;
throw error;
}
}
}

View file

@ -154,7 +154,36 @@ function consumeInteractionOwnership(ownership, entries) {
if (event.type === 'turn/end' && event.data?.turn === ownership.turn) {
ownership.active = false;
ownership.completed = true;
continue;
}
let toolCall = null;
if (event.type === 'tool/call'
&& ownership.active
&& event.data?.turn === ownership.turn
&& typeof event.data?.callId === 'string'
&& event.data.callId) {
toolCall = {
callId: event.data.callId,
name: event.data?.name,
arguments: event.data?.arguments,
};
} else if (event.type === 'tool/code-dispatch-start'
&& ownership.active
&& typeof event.data?.subCallId === 'string'
&& event.data.subCallId) {
let argumentsText;
try {
argumentsText = JSON.stringify(event.data?.arguments);
} catch {
argumentsText = undefined;
}
toolCall = {
callId: event.data.subCallId,
name: event.data?.name,
arguments: argumentsText,
};
}
if (toolCall) ownership.toolCalls.set(toolCall.callId, Object.freeze(toolCall));
}
}
@ -558,13 +587,12 @@ export class HarnessClient {
.sort((left, right) => left.order - right.order);
if (active.length > 0) return { ownership: active[0], recovered: false };
// Approval recovery must remain fail-closed until #5 can prove the actor
// and conversation that originally requested the decision.
if (kind !== 'question') return null;
// A newly attached IM conversation may encounter a question left by
// an earlier runtime before its queued prompt starts. Let the oldest such
// ask adopt that replay so the Session can recover instead of deadlocking.
// Approval adopters receive recovered=true and must reject it without ever
// presenting it as approvable; the original actor/route cannot be proven
// after a runtime restart.
const ownership = owners
.filter((ownership) => !ownership.started && !ownership.completed)
.sort((left, right) => left.order - right.order)[0] ?? null;
@ -614,6 +642,7 @@ export class HarnessClient {
lastSeq: baselineSeq,
reconnect: null,
order: -1,
toolCalls: new Map(),
}
: null;
let interactionTask = null;
@ -774,6 +803,9 @@ export class HarnessClient {
if (claim?.ownership !== ownership) return;
this.#interactionClaims.set(claimKey, claim);
}
const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string'
? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId)
: undefined;
dispatch(onInteraction, Object.freeze({
kind,
interactionId,
@ -783,6 +815,7 @@ export class HarnessClient {
recovered: ownership
? this.#interactionClaims.get(claimKey)?.recovered === true
: false,
...(toolCall ? { toolCall } : {}),
reconnect: close,
respond: (result, options = {}) => this.respondInteraction(
envelope.rpcId,

View file

@ -1,5 +1,6 @@
import { runWorkspaceCommand } from './workspace-command.mjs';
import { askInWorkspaceSession } from './workspace-session.mjs';
import { HarnessApprovalQueue } from './harness-approval.mjs';
import {
harnessAnswerForQuestion,
harnessQuestionText,
@ -43,6 +44,8 @@ export class TextHarnessBridge {
#pendingInteractions = new Map();
#interactionKeys = new Map();
#acceptedMessageIds = new Set();
#approvalTasks = new Set();
#approvals;
constructor({
descriptor,
@ -65,6 +68,10 @@ export class TextHarnessBridge {
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#signal = signal;
this.#approvals = new HarnessApprovalQueue({
label: descriptor.key,
logger,
});
}
get status() {
@ -86,6 +93,35 @@ export class TextHarnessBridge {
const key = `${kind}:${conversationId}`;
const pending = this.#pendingInteractions.get(key);
const approval = this.#approvals.claimReply({
key,
actor: senderId,
messageId,
text: normalized.content,
addressed: normalized.kind !== 'group' || normalized.addressed === true,
hasPendingQuestion: Boolean(pending),
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
? pending.queue
: null,
isQuestionPending: () => this.#pendingInteractions.has(key),
send: (text) => this.#bot.sendText(normalized.replyTarget, text),
});
if (approval) {
let task;
task = approval.process(async () => {
if (this.#state.hasSeen(messageId)) return false;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
return true;
})
.finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#approvalTasks.delete(task);
});
this.#approvalTasks.add(task);
return task;
}
if (pending && pending.actor !== senderId) {
return this.#enqueueMessage(normalized, messageId, senderId, key);
}
@ -147,6 +183,7 @@ export class TextHarnessBridge {
...[...this.#pendingInteractions.values()].flatMap((pending) => (
pending.queue ? [pending.queue] : []
)),
...this.#approvalTasks,
]);
}
@ -276,7 +313,10 @@ export class TextHarnessBridge {
);
}
} finally {
await this.#cancelPendingInteraction(conversationKey);
await Promise.allSettled([
this.#cancelPendingInteraction(conversationKey),
this.#approvals.closeRoute(conversationKey),
]);
}
}
@ -434,8 +474,14 @@ export class TextHarnessBridge {
target,
requiresMention,
}) {
// Approval remains deliberately unanswered until #5 supplies a policy that
// can prove both the actor and the conversation allowed to decide it.
if (interaction?.kind === 'approval') {
return this.#approvals.handleRequested(interaction, {
key,
actor,
requiresMention,
send: (text) => this.#bot.sendText(target, text),
});
}
if (interaction?.kind !== 'question') return;
const questions = interaction?.payload?.questions;
const interactionId = cleanText(interaction?.interactionId) || cleanText(interaction?.rpcId);
@ -510,7 +556,11 @@ export class TextHarnessBridge {
await this.#presentInteraction(pending);
}
#handleInteractionResolved(resolution) {
async #handleInteractionResolved(resolution) {
if (resolution?.kind === 'approval') {
await this.#approvals.handleResolved(resolution);
return;
}
const interactionId = cleanText(resolution?.interactionId);
if (resolution?.kind !== 'question' || !interactionId) return;
const key = this.#interactionKeys.get(interactionId);