blob: 90239791a530c4842de49ba981c558acb1939274 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { TurnOrigin } from '@maka/core/turn-origin';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { ASSISTANT_BACKGROUND_SESSION_LABEL } from '@maka/core/session';
import type { MessageContent } from '@maka/core/events';
import type { AssistantModuleServices } from '../service.js';
import type { InitiativeStore } from '../index-initiative/store.js';
import { executionFailure } from '../execution-outcome.js';
import { BotStore, type InboxInput } from './store.js';
import { assistantErrorOutputTurnId, assistantInputTurnId } from './turn-ids.js';
const UNREAD_SLEEP_FEEDBACK =
'本次未能休眠:你读取 Inbox 后又收到新消息,或上一批还有未投递消息。请调用 InboxCheck 接收正文,再自行决定如何处理;不要求回复或完成每件事,之后可再次调用 Sleep。';
export class BotController {
readonly abort = new AbortController();
private timer?: ReturnType<typeof setInterval>;
private running?: Promise<void>;
private flushing?: Promise<void>;
private activeAgent?: any;
private deliveredThrough = 0;
private deliveries = new Map<string, ReturnType<BotStore['batch']>>();
checkInbox(callId: string, limit: number) {
this.ready();
const previous = this.deliveries.get(callId);
if (previous) return previous;
const batch = this.store.batch(this.deliveredThrough, limit);
this.deliveredThrough = batch.through;
this.deliveries.set(callId, batch);
return batch;
}
isWorking() {
return !!this.activeAgent && !this.store.get('sleepTurn') && !this.store.get('error');
}
isSleeping() {
return (
!this.running &&
!this.store.get('error') &&
this.store.head() <= (this.store.get<number>('deliveredThrough') ?? 0)
);
}
constructor(
readonly ctx: AssistantModuleServices,
readonly binding: InitiativeStore,
readonly store: BotStore,
) {}
owner() {
return this.binding.get()?.sessionId as string | undefined;
}
worker() {
return this.store.get<string>('worker');
}
invocation(sessionId = this.owner()!) {
return {
sessionId,
cwd: this.binding.get().cwd,
emitOutput: () => {},
turnId: 'assistant-bot',
toolCallId: randomUUID(),
abortSignal: this.abort.signal,
};
}
start() {
this.timer = setInterval(() => this.tick(), 1000);
this.timer.unref?.();
this.tick();
}
ready() {
this.abort.signal.throwIfAborted();
this.binding.fence();
}
post(input: InboxInput) {
this.ready();
this.store.post(input);
this.tick();
}
async receive(
id: string,
content: MessageContent,
origin?: Extract<TurnOrigin, { kind: 'external_connector' }>,
) {
this.ready();
this.store.post(
{
id,
type: 'user_message',
source: origin ? 'external_connector' : 'user',
content: origin ? { ...content, origin } : content,
},
{
...content,
type: 'user',
...(origin ? { origin } : {}),
id,
turnId: assistantInputTurnId(id),
ts: Date.now(),
},
);
await this.flush();
this.tick();
}
async flush(): Promise<void> {
if (this.flushing) {
await this.flushing;
if (this.store.pendingVisible().length) await this.flush();
return;
}
this.flushing = (async () => {
for (const message of this.store.pendingVisible()) {
this.ready();
if (!this.ctx.appendConversation)
throw Error('Assistant conversation transport unavailable');
await this.ctx.appendConversation(this.owner()!, message);
this.store.delivered(message.id);
}
})().finally(() => {
this.flushing = undefined;
});
return this.flushing;
}
async agent() {
return this.ctx.agents.withInvocation(this.invocation(), async () => {
let id = this.worker();
if (!id) {
const label = `assistant-bot:${this.owner()}`;
const matches = (await this.ctx.sessionQuery.list()).filter((s: any) =>
s.labels?.includes(label),
);
if (matches.length > 1)
throw Error('Multiple Bot workers found; cannot choose a conversation');
id = matches[0]?.id;
if (!id)
id = (
await this.ctx.agents.create({
background: true,
name: '个人助手 · Bot',
labels: [label, ASSISTANT_BACKGROUND_SESSION_LABEL],
})
).sessionId;
this.store.set('worker', id);
}
return this.ctx.agents.resume({ sessionId: id! });
});
}
tick() {
if (this.running || this.abort.signal.aborted || !this.owner()) return;
try {
this.ready();
} catch {
return;
}
const attemptedThrough = this.store.head();
this.running = this.run()
.catch(async (error) => {
if (!this.abort.signal.aborted) {
this.store.transaction(() => {
this.store.set('error', String(error));
// Mail arriving during a failed execution must still trigger a new
// attempt. Do not latch the failure against that newer input.
this.store.set('errorThrough', attemptedThrough);
const id = randomUUID();
this.store.queueVisible({
type: 'assistant',
id: `bot-error:${id}`,
turnId: assistantErrorOutputTurnId(id),
ts: Date.now(),
text: '助手消息处理暂时停止。消息已保留;请检查当前模型和连接,发送新消息可重试。',
modelId: 'assistant-bot',
});
});
// A publication may have committed before its response was lost.
// Keep the outbox entry for the next tick without replacing the failure.
await this.flush().catch(() => {});
}
})
.finally(() => {
this.running = undefined;
});
}
private async run() {
try {
await this.flush();
} catch (error) {
// Pending publication is retried independently of model execution. A
// delivery failure cannot consume the retry triggered by fresh Inbox mail.
if (!this.abort.signal.aborted) this.store.set('wakeError', String(error));
return;
}
this.store.set('wakeError', null);
if (this.store.get('error')) {
const errorThrough = this.store.get<number>('errorThrough');
if (errorThrough !== undefined && this.store.head() <= errorThrough) return;
// Older versions persisted an error without an input boundary. Reconcile
// once on recovery; subsequent failures wait for fresh input, not a timer storm.
this.store.set('error', null);
}
if (this.store.head() <= (this.store.get<number>('deliveredThrough') ?? 0)) {
this.store.set('wake', null);
this.store.set('wakeError', null);
return;
}
// Commit delivery only at a normal Sleep boundary. An interrupted loop may
// replay mail, but cannot lose it between a tool return and durable history.
this.deliveredThrough = this.store.get<number>('deliveredThrough') ?? 0;
this.deliveries.clear();
const agent = await this.agent();
this.activeAgent = agent;
try {
const snapshot: any = await agent.snapshot();
const status = snapshot?.agent?.status;
if (
status === 'waiting_for_user' ||
snapshot?.pendingInteractions > 0 ||
(status === 'blocked' && snapshot?.pendingInteractions !== 0)
)
throw Error('Bot requires attention in its worker Session');
let wake = this.store.get<{ messageId: string; text: string }>('wake');
if (wake || status !== 'running') {
if (!wake) {
this.store.set('sleepTurn', null);
wake = {
messageId: randomUUID(),
text: `Inbox 有新消息。当前 UTC 时间:${new Date().toISOString()}。用 InboxCheck 批量接收新消息正文,自主决定如何处理;不需要轮询。`,
};
// Persist before submission: an ambiguous response must not create another wake.
this.store.set('wake', wake);
}
for (;;) {
this.ready();
let resolution: any;
try {
resolution = await agent.messageExecution(wake.messageId);
} catch (error) {
this.store.set('wakeError', String(error));
return;
}
if (resolution?.state === 'not_admitted') {
// Only a new admission may change the execution model. Recovering
// an owned or pending wake must keep observing that execution.
const prepared = await this.ctx.prepareBotWake(this.owner()!, this.worker()!);
if (prepared === 'deferred') return;
this.ready();
try {
const admitted: any = await agent.followup(wake.text, { messageId: wake.messageId });
if (!['turn_started', 'followup'].includes(admitted?.disposition))
throw Error('Bot wake was not admitted');
if (admitted.disposition === 'turn_started') {
this.store.set('wakeError', null);
break;
}
} catch (error) {
// Retry reconciliation on the next scheduler tick, keeping the submission identity.
this.store.set('wakeError', String(error));
return;
}
}
this.store.set('wakeError', null);
if (resolution?.state === 'owned') {
const current: any = await agent.snapshot();
const currentStatus = current?.agent?.status;
if (
!this.store.get('sleepTurn') &&
(['idle', 'active', 'aborted'].includes(currentStatus) ||
(currentStatus === 'blocked' && current?.pendingInteractions === 0))
) {
// An admitted wake can outlive its interrupted execution. Keep its
// history, but persist a fresh identity before resuming unread mail.
wake = { ...wake, messageId: randomUUID() };
this.store.set('wake', wake);
continue;
}
break;
}
// Pending submissions may deliberately be omitted from query results.
if (!resolution) {
await delay(1000, undefined, { signal: this.abort.signal });
continue;
}
if (resolution?.state === 'cancelled') {
this.store.set('wake', null);
throw Error('Bot wake was cancelled; Inbox is preserved');
}
if (!['pending', 'not_admitted'].includes(resolution?.state))
throw Error('Unexpected Bot wake execution state: ' + resolution?.state);
await delay(1000, undefined, { signal: this.abort.signal });
}
}
try {
await agent.whenIdle(this.abort.signal);
} catch (error) {
// A failed execution is terminal; a failed observation is not.
if (!this.abort.signal.aborted) {
const stopped: any = await agent.snapshot();
if (
['active', 'idle', 'aborted'].includes(stopped?.agent?.status) ||
(stopped?.agent?.status === 'blocked' && stopped?.pendingInteractions === 0)
)
this.store.set('wake', null);
}
throw error;
}
const finalStatus = ((await agent.snapshot()) as any)?.agent?.status;
this.store.set('wake', null);
if (
['aborted', 'blocked', 'waiting_for_user'].includes(finalStatus) ||
!this.store.get('sleepTurn')
) {
let message = ['aborted', 'blocked', 'waiting_for_user'].includes(finalStatus)
? 'Bot stopped: ' + finalStatus
: 'Bot ended without Sleep; send a new message to retry';
// A contained provider failure can settle whenIdle normally. Resolve
// this wake's latest execution rather than reporting a stale turn error.
if (wake) {
const execution: any = await agent
.messageExecution(wake.messageId)
.catch(() => undefined);
if (execution?.state === 'owned' && execution.turnId) {
const failure = await executionFailure(agent, execution.turnId).catch(() => undefined);
if (failure) message = failure.message;
}
}
throw Error(message);
}
} finally {
this.activeAgent = undefined;
}
}
sleep(turnId: string) {
this.ready();
if (this.store.head() > this.deliveredThrough) {
this.store.set('sleepTurn', null);
return { sleeping: false, notice: UNREAD_SLEEP_FEEDBACK };
}
this.store.transaction(() => {
this.store.set('sleepThrough', this.deliveredThrough);
this.store.set('sleepTurn', turnId);
});
return {
sleeping: true,
notice: '本轮停止工作;新消息将唤醒你。已投递消息仍可回查,不会因未回复而重复唤醒。',
};
}
beforeFinish(turnId: string) {
this.ready();
if (
this.store.get('sleepTurn') === turnId &&
this.store.head() <= (this.store.get<number>('sleepThrough') ?? 0)
) {
this.store.set('deliveredThrough', this.deliveredThrough);
return { allow: true as const };
}
this.store.set('sleepTurn', null);
return {
allow: false as const,
feedback:
this.store.head() > this.deliveredThrough
? UNREAD_SLEEP_FEEDBACK
: '你是 Inbox 驱动的 Bot。普通文本不会发送给用户,也不会让你休眠。需要交流用 SendUser,无需继续时调用 Sleep。若 Inbox 还有未投递消息,用 InboxCheck 接收正文后自行决定如何处理;读取不要求回复或完成事情。',
};
}
async close() {
clearInterval(this.timer);
this.abort.abort(Error('Assistant Bot stopped'));
if (this.activeAgent) await this.activeAgent.cancel();
await Promise.allSettled([this.running, this.flushing].filter(Boolean));
}
}