diff --git a/.gitignore b/.gitignore index 40381bd88..5f3c00fde 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ tsconfig.temp.json .DS_Store .idea .vscode +.chatluna *.suo *.ntvs* *.njsproj @@ -23,4 +24,4 @@ packages/*/dist packages/*/lib .VSCodeCounter -.yarn/install-state.gz \ No newline at end of file +.yarn/install-state.gz diff --git a/packages/core/package.json b/packages/core/package.json index 68979e3b9..120c80cc1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -142,6 +142,11 @@ "import": "./lib/utils/koishi.mjs", "require": "./lib/utils/koishi.cjs" }, + "./utils/virtual_session": { + "types": "./lib/utils/virtual_session.d.ts", + "import": "./lib/utils/virtual_session.mjs", + "require": "./lib/utils/virtual_session.cjs" + }, "./utils/langchain": { "types": "./lib/utils/langchain.d.ts", "import": "./lib/utils/langchain.mjs", diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 62317a3c8..0ea86f1af 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -31,6 +31,7 @@ export interface Config { autoPurgeArchiveTimeout: number messageQueue: boolean messageQueueDelay: number + agentTaskAutoWakeup: boolean infiniteContext: boolean infiniteContextThreshold: number rawOnCensor: boolean @@ -117,6 +118,7 @@ export const Config: Schema = Schema.intersect([ .min(0) .max(60 * 30) .default(0), + agentTaskAutoWakeup: Schema.boolean().default(true), showThoughtMessage: Schema.boolean().default(false) }), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index edbfeb708..d9a107939 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ import * as request from 'koishi-plugin-chatluna/utils/request' import { PromiseLikeDisposable } from 'koishi-plugin-chatluna/utils/types' import { command } from './command' import { Config } from './config' +import { applyAgentTaskWakeup } from './llm-core/agent/wakeup' import { defaultFactory } from './llm-core/chat/default' import { apply as loreBook } from './llm-core/memory/lore_book' import { apply as authorsNote } from './llm-core/memory/authors_note' @@ -108,6 +109,7 @@ async function initializeComponents(ctx: Context, config: Config) { await ctx.chatluna.preset.init() await setupAutoArchive(ctx, config) await setupAutoPurgeArchive(ctx, config) + applyAgentTaskWakeup(ctx, config) loreBook(ctx, config) authorsNote(ctx, config) } diff --git a/packages/core/src/llm-core/agent/agent.ts b/packages/core/src/llm-core/agent/agent.ts index 81008e45e..64a05ad15 100644 --- a/packages/core/src/llm-core/agent/agent.ts +++ b/packages/core/src/llm-core/agent/agent.ts @@ -58,6 +58,7 @@ export interface AgentGenerateOptions { signal?: AbortSignal maxToken?: number messageQueue?: MessageQueue + pauseGate?: (signal?: AbortSignal) => Promise toolMask?: ToolMask subagentContext?: SubagentContext source?: 'chatluna' | 'character' @@ -161,6 +162,7 @@ export function createAgent(options: CreateAgentOptions): ChatLunaAgent { const bound = runner.value.withConfig({ configurable: { messageQueue: input.messageQueue, + pauseGate: input.pauseGate, onAgentEvent: input.onStep, agentContext: ctx } diff --git a/packages/core/src/llm-core/agent/legacy-executor.ts b/packages/core/src/llm-core/agent/legacy-executor.ts index d42b47718..b57f52792 100644 --- a/packages/core/src/llm-core/agent/legacy-executor.ts +++ b/packages/core/src/llm-core/agent/legacy-executor.ts @@ -215,6 +215,8 @@ export async function* runAgent( while (iterations < maxIterations) { checkAborted(signal) + await runtime.pauseGate?.(signal) + checkAborted(signal) const pending = queue?.drain() ?? [] if (pending.length > 0) { diff --git a/packages/core/src/llm-core/agent/sub-agent.ts b/packages/core/src/llm-core/agent/sub-agent.ts index bcb65ecac..64cc3fcf5 100644 --- a/packages/core/src/llm-core/agent/sub-agent.ts +++ b/packages/core/src/llm-core/agent/sub-agent.ts @@ -8,6 +8,7 @@ import { import { StructuredTool } from '@langchain/core/tools' import { randomUUID } from 'crypto' import type { Awaitable, Session } from 'koishi' +import { logger } from 'koishi-plugin-chatluna' import { z } from 'zod' import type { ChatLunaToolRunnable } from '../platform/types' import { getMessageContent } from 'koishi-plugin-chatluna/utils/string' @@ -16,126 +17,13 @@ import { observationToMessageContent } from './legacy-executor' import { MessageQueue } from './types' import type { AgentEvent, AgentStep, SubagentContext, ToolMask } from './types' -export interface AgentTaskDescriptor { - id: string - name: string - description: string -} - -export interface AgentTaskTarget { - agent: ChatLunaAgent - toolMask?: ToolMask -} - -export interface AgentTaskSession { - id: string - agentId: string - agentName: string - conversationId: string - parentConversationId: string - depth: number - maxDepth: number - parentAgent: string - activeRunId?: string - messages: BaseMessage[] - startedAt: number - updatedAt: number -} - -export interface AgentTaskRunTraceEntry { - id: string - type: - | 'prompt' - | 'message' - | 'thought' - | 'tool-call' - | 'tool-result' - | 'output' - | 'error' - at: number - text: string - tool?: string - title?: string - callId?: string -} - -export interface AgentTaskRun { - runId: string - taskId: string - agentId: string - agentName: string - conversationId: string - parentConversationId: string - depth: number - state: 'running' | 'completed' | 'failed' | 'aborted' - background?: boolean - startedAt: number - endedAt?: number - lastTool?: string - toolCount: number - turnCount: number - error?: string - output?: string - trace: AgentTaskRunTraceEntry[] -} - -export interface AgentTaskInput { - action?: 'run' | 'status' | 'list' | 'message' - agent?: string - id?: string - prompt?: string - reason?: string - background?: boolean - message?: string -} - -export interface AgentTaskQueryContext { - session?: Session - source?: 'chatluna' | 'character' -} - -export interface AgentTaskResolveContext extends AgentTaskQueryContext { - conversationId?: string - parent?: SubagentContext - runConfig?: ChatLunaToolRunnable -} - -export interface CreateTaskToolOptions { - list: (ctx: AgentTaskQueryContext) => Awaitable - get: ( - name: string, - ctx: AgentTaskResolveContext - ) => Awaitable - refresh?: () => Awaitable - maxDepth?: number - taskTtl?: number - runTtl?: number - name?: string -} - -export interface AgentTaskToolRuntime { - buildToolDescription(): string - createTool(): StructuredTool - dispose(): Promise - getRuns(): AgentTaskRun[] - getTasks(): AgentTaskSession[] - runTask( - input: AgentTaskInput, - runConfig?: ChatLunaToolRunnable - ): Promise -} - -interface ActiveAgentTaskRun { - abort: AbortController - queue: MessageQueue -} - export function createTaskTool( options: CreateTaskToolOptions ): AgentTaskToolRuntime { const tasks = new Map() const runs = new Map() const active = new Map() + const snapshots = new Map() const runDispose = new Map void>() const taskDispose = new Map void>() const toolName = options.name ?? 'task' @@ -159,6 +47,7 @@ export function createTaskTool( clearDisposers(taskDispose) tasks.clear() runs.clear() + snapshots.clear() }, getRuns() { return [...runs.values()].sort((a, b) => b.startedAt - a.startedAt) @@ -166,6 +55,125 @@ export function createTaskTool( getTasks() { return [...tasks.values()].sort((a, b) => b.updatedAt - a.updatedAt) }, + getTask(id) { + return tasks.get(id) + }, + async stopTask(id) { + const task = tasks.get(id) + const runId = task?.activeRunId + const item = runId ? active.get(runId) : undefined + if (!runId || !item) return false + + const run = runs.get(runId) + if (run) run.paused = false + item.paused = false + item.resume?.() + item.abort.abort() + try { + await options.refresh?.() + } catch (err) { + logger.error(err) + } + return true + }, + async pauseTask(id) { + const runId = tasks.get(id)?.activeRunId + const item = runId ? active.get(runId) : undefined + if (!runId || !item || item.paused) return false + + item.paused = true + const run = runs.get(runId) + if (run) run.paused = true + try { + await options.refresh?.() + } catch (err) { + logger.error(err) + } + return true + }, + async resumeTask(id) { + const runId = tasks.get(id)?.activeRunId + const item = runId ? active.get(runId) : undefined + if (!runId || !item?.paused) return false + + item.paused = false + const run = runs.get(runId) + if (run) run.paused = false + item.resume?.() + try { + await options.refresh?.() + } catch (err) { + logger.error(err) + } + return true + }, + async abortByParentConversation(id) { + let count = 0 + for (const task of tasks.values()) { + if (task.parentConversationId !== id || !task.activeRunId) { + continue + } + + const item = active.get(task.activeRunId) + if (!item) continue + + const run = runs.get(task.activeRunId) + if (run) run.paused = false + item.paused = false + item.resume?.() + item.abort.abort() + count += 1 + } + + if (count > 0) { + try { + await options.refresh?.() + } catch (err) { + logger.error(err) + } + } + return count + }, + async chatTask(id, prompt, ctx) { + const task = tasks.get(id) + if (!task) { + return { + state: 'failed', + output: `Task '${id}' was not found or expired.` + } + } + + const runId = task.activeRunId + const item = runId ? active.get(runId) : undefined + if (runId && item) { + item.queue.push(new HumanMessage(prompt)) + touchTaskSession(task) + scheduleTaskCleanup(task.id) + await options.refresh?.() + return { state: 'queued' } + } + + if (runId) { + return { + state: 'failed', + output: `Task '${task.id}' is not accepting live messages because it was not started in background.` + } + } + + const output = await runtime.runTask( + { + action: 'run', + id, + prompt + }, + ctx.runConfig + ) + const run = getLatestTaskRun(runs, id) + return { + state: run?.state ?? 'completed', + output + } + }, async runTask(input, runConfig) { const action = input.action ?? 'run' const parent = @@ -250,10 +258,8 @@ export function createTaskTool( return [ `task_id: ${task.id}`, - `run_id: ${task.activeRunId}`, 'state: running', - 'message: queued', - `status_hint: use ${toolName} with {"action":"status","id":"${task.id}"} to inspect progress.` + 'hint: guidance delivered; result will arrive automatically. Do not poll status.' ].join('\n') } @@ -299,7 +305,7 @@ export function createTaskTool( } if (next.activeRunId) { - return `Task '${next.id}' is already running. Use action=status to inspect it or action=message to guide it while it runs in background.` + return `Task '${next.id}' is already running; result will arrive automatically. Use action=message to guide it.` } const raw = input.prompt?.trim() @@ -319,8 +325,10 @@ export function createTaskTool( input, toolName, runtime: options, + tasks, runs, active, + snapshots, scheduleRunCleanup, scheduleTaskCleanup, task: next, @@ -368,6 +376,7 @@ export function createTaskTool( createTimeout(async () => { runDispose.delete(runId) runs.delete(runId) + snapshots.delete(runId) await options.refresh?.() }, runTtl) ) @@ -398,6 +407,7 @@ export function createTaskTool( cancelRunCleanup(run.runId) runs.delete(run.runId) + snapshots.delete(run.runId) } await options.refresh?.() @@ -408,12 +418,9 @@ export function createTaskTool( export function buildTaskToolDescription() { return [ - 'Delegate a focused task to a specialized agent when parallel work, deeper investigation, or a narrower prompt will help.', - 'Use the exact agent name from the injected catalog.', - 'If delegated work may take a while, set background=true so it can continue beyond the normal tool timeout.', - 'Use action=list or action=status to inspect background tasks, ' + - 'action=message to send more guidance while they run, and ' + - 'action=run with the same id to continue the same session later.' + 'Delegate focused work to a specialist agent (exact name required).', + 'Set background=true for long tasks; results are delivered to you automatically - never poll status.', + 'Actions: run (new task, or resume with id), status, list, message (guide a running background task).' ].join('\n') } @@ -424,9 +431,7 @@ export function renderAvailableAgents( ) { const lines = [ '', - 'Delegate focused work to a specialist via the task tool when parallel work or a narrower prompt helps.', - 'If delegated work may take a while or exceed the normal tool timeout, set background=true, then query it later with task action=list/status.', - 'While a background sub-agent is running, you can send more guidance with task action=message.', + 'Delegate via the task tool. background=true for long work; results arrive automatically - do not poll status.', '' ] @@ -440,17 +445,13 @@ export function renderAvailableAgents( for (const item of agents) { lines.push( - ' ', - ` ${escapeXml(item.name)}`, - ` ${escapeXml(item.description)}`, - ' ' + `${escapeXml(item.description)}` ) } lines.push( '', - 'Use the exact sub-agent name. Provide a self-contained prompt with goal, context, and expected result.', - 'Prefer background=true for long-running delegated work so it is not interrupted by the default timeout.', + 'Use exact names. Include goal, context, and expected result in the prompt.', '' ) @@ -468,28 +469,22 @@ class AgentTaskTool extends StructuredTool { .enum(['run', 'status', 'list', 'message']) .optional() .describe( - 'run starts or resumes an agent task, status inspects one task, ' + - 'list shows recent tasks in this conversation, message sends ' + - 'live guidance to a running background task.' + 'run/resume task, status inspects one, list shows recent, message guides a running background task.' ), agent: z .string() .optional() .describe( - 'The exact agent name from the injected catalog. Required when starting a new task. Optional when resuming an existing task by id.' + 'Exact agent name. Required for new tasks; optional when resuming by id.' ), id: z .string() .optional() - .describe( - 'Existing task id returned by an earlier task call. Reuse it to inspect, message, or continue the same agent session.' - ), + .describe('Existing task id for status, message, or resume.'), prompt: z .string() .optional() - .describe( - 'The delegated task or follow-up instruction. Required when action is run.' - ), + .describe('Task or follow-up instruction. Required for run.'), reason: z .string() .optional() @@ -497,15 +492,11 @@ class AgentTaskTool extends StructuredTool { background: z .boolean() .optional() - .describe( - 'Run the agent in the background. Prefer this for long-running work so it can continue beyond the normal tool timeout.' - ), + .describe('Run in background for long work.'), message: z .string() .optional() - .describe( - 'Live guidance to send to a running background agent. Use with action message.' - ) + .describe('Guidance for a running background task.') }) .superRefine((input, ctx) => { const action = input.action ?? 'run' @@ -560,8 +551,10 @@ async function runAgentTask(options: { input: AgentTaskInput toolName: string runtime: CreateTaskToolOptions + tasks: Map runs: Map active: Map + snapshots: Map scheduleRunCleanup: (runId: string) => void scheduleTaskCleanup: (taskId: string) => void task: AgentTaskSession @@ -616,13 +609,33 @@ async function runAgentTask(options: { const abort = options.input.background ? new AbortController() : undefined const queue = options.input.background ? new MessageQueue() : undefined + const activeRun: ActiveAgentTaskRun | undefined = + abort && queue ? { abort, queue } : undefined const signal = abort?.signal ?? options.signal const promptMessage = new HumanMessage(options.prompt) + const snapshot: AgentTaskSessionSnapshot | undefined = options.input + .background + ? { + session: options.session, + routing: { + platform: options.session.platform, + selfId: options.session.selfId, + userId: options.session.userId, + username: options.session.username ?? undefined, + guildId: options.session.guildId ?? undefined, + channelId: options.session.channelId ?? undefined, + isDirect: options.session.isDirect ?? false + } + } + : undefined options.task.activeRunId = runId options.runs.set(runId, run) - if (abort && queue) { - options.active.set(runId, { abort, queue }) + if (snapshot) { + options.snapshots.set(runId, snapshot) + } + if (activeRun) { + options.active.set(runId, activeRun) } options.scheduleTaskCleanup(options.task.id) run.trace.push({ @@ -658,6 +671,25 @@ async function runAgentTask(options: { history: [...options.task.messages], signal, messageQueue: queue, + pauseGate: activeRun + ? async (signal) => { + while (activeRun.paused) { + if (signal?.aborted) return + + await new Promise((resolve) => { + const done = () => { + signal?.removeEventListener('abort', done) + activeRun.resume = undefined + resolve() + } + activeRun.resume = done + signal?.addEventListener('abort', done, { + once: true + }) + }) + } + } + : undefined, toolMask, subagentContext: subCtx, source: options.source, @@ -683,7 +715,12 @@ async function runAgentTask(options: { touchTaskSession(options.task) options.scheduleRunCleanup(runId) options.scheduleTaskCleanup(options.task.id) - await options.runtime.refresh?.() + await notifyFinished(options, run, snapshot) + try { + await options.runtime.refresh?.() + } catch (err) { + logger.error(err) + } return formatTaskResult( options.task, run, @@ -705,7 +742,12 @@ async function runAgentTask(options: { touchTaskSession(options.task) options.scheduleRunCleanup(runId) options.scheduleTaskCleanup(options.task.id) - await options.runtime.refresh?.() + await notifyFinished(options, run, snapshot) + try { + await options.runtime.refresh?.() + } catch (err) { + logger.error(err) + } throw err } finally { options.active.delete(runId) @@ -714,12 +756,64 @@ async function runAgentTask(options: { if (options.input.background) { exec().catch(() => {}) - return formatTaskStart(options.task, run, options.toolName) + return formatTaskStart(options.task, options.toolName) } return await exec() } +async function notifyFinished( + options: { + input: AgentTaskInput + runtime: CreateTaskToolOptions + tasks: Map + active: Map + task: AgentTaskSession + target: AgentTaskTarget + source: 'chatluna' | 'character' + }, + run: AgentTaskRun, + snapshot?: AgentTaskSessionSnapshot +) { + if (!options.input.background || run.state === 'aborted') { + return + } + + let parentId = options.task.parentConversationId + const message = new HumanMessage( + formatAgentTaskWakeup(options.task.id, options.task.agentName, run) + ) + + while (parentId.startsWith('subagent:')) { + const task = options.tasks.get(parentId.slice('subagent:'.length)) + const item = task?.activeRunId + ? options.active.get(task.activeRunId) + : undefined + if (item) { + item.queue.push(message) + return + } + + if (!task) { + return + } + + parentId = task.parentConversationId + } + + try { + await options.runtime.onRunFinished?.({ + run, + taskId: options.task.id, + agentId: options.target.agent.id, + agentName: options.target.agent.name, + parentConversationId: parentId, + source: options.source, + snapshot + }) + } catch {} +} + async function onTaskEvent( task: AgentTaskSession, run: AgentTaskRun, @@ -899,32 +993,44 @@ function formatTaskResult( toolName: string ) { return [ + output.trim() || '(empty)', + '', `task_id: ${task.id}`, `agent: ${task.agentName}`, - `run_id: ${run.runId}`, `state: ${run.state}`, - `resume_hint: use ${toolName} with {"action":"run","id":"${task.id}","prompt":"next instruction"} ` + - 'to continue this session. Add "background":true when the work may take a while.', - '', - output.trim() || '(empty)' + `hint: use ${toolName} action=run id=${task.id} to continue` ].join('\n') } -function formatTaskStart( - task: AgentTaskSession, - run: AgentTaskRun, - toolName: string -) { +function formatTaskStart(task: AgentTaskSession, toolName: string) { return [ `task_id: ${task.id}`, `agent: ${task.agentName}`, - `run_id: ${run.runId}`, - 'state: running', - 'mode: background', - `status_hint: use ${toolName} with {"action":"status","id":"${task.id}"} to inspect progress.`, - `list_hint: use ${toolName} with {"action":"list"} to see recent agent tasks in this conversation.`, - `message_hint: use ${toolName} with {"action":"message","id":"${task.id}","message":"..."} to send more guidance while it runs.`, - `resume_hint: after it stops, use ${toolName} with {"action":"run","id":"${task.id}","prompt":"next instruction"} to continue this session.` + 'state: running (background)', + 'hint: result will be delivered automatically - do NOT poll status. ' + + `Continue other work or end your reply; use ${toolName} ` + + `action=message id=${task.id} to send guidance.` + ].join('\n') +} + +export function formatAgentTaskWakeup( + taskId: string, + agentName: string, + run: Pick +) { + return [ + ``, + escapeXml( + run.state === 'failed' ? (run.error ?? '') : (run.output ?? '') + ), + '', + '', + run.state === 'failed' + ? 'Automatic notice: a background task you started failed. ' + + `Report the failure or retry with task action=run id=${taskId}.` + : 'Automatic notice: a background task you started finished. ' + + 'Use the result to respond to the user; continue it with ' + + `task action=run id=${taskId} if needed.` ].join('\n') } @@ -941,13 +1047,11 @@ function formatTaskList( task.id, `[${run?.state ?? (task.activeRunId ? 'running' : 'idle')}]`, task.agentName, - `mode=${run?.background ? 'background' : 'foreground'}`, - `updated=${new Date(task.updatedAt).toISOString()}`, - `run=${run?.runId ?? task.activeRunId ?? '-'}` + `mode=${run?.background ? 'background' : 'foreground'}` ].join(' ') }), '', - `Use ${toolName} with {"action":"status","id":"..."} to inspect one task.` + `Use ${toolName} action=status id=...` ].join('\n') } @@ -956,63 +1060,32 @@ function formatTaskDetail( run: AgentTaskRun | undefined, toolName: string ) { - const lines = [ + const meta = [ `task_id: ${task.id}`, `agent: ${task.agentName}`, `state: ${run?.state ?? (task.activeRunId ? 'running' : 'idle')}`, - `mode: ${run?.background ? 'background' : 'foreground'}`, - `run_id: ${run?.runId ?? task.activeRunId ?? '-'}`, - `depth: ${task.depth}`, - `parent_agent: ${task.parentAgent}`, - `started: ${new Date(task.startedAt).toISOString()}`, - `updated: ${new Date(task.updatedAt).toISOString()}` + `mode: ${run?.background ? 'background' : 'foreground'}` ] - if (run?.lastTool) { - lines.push(`last_tool: ${run.lastTool}`) - } - - if (run) { - lines.push(`tool_count: ${run.toolCount}`) - lines.push(`turn_count: ${run.turnCount}`) - } - - if (run?.endedAt) { - lines.push(`ended: ${new Date(run.endedAt).toISOString()}`) - } - if (run?.error) { - lines.push(`error: ${run.error}`) + meta.push(`error: ${run.error}`) } - lines.push( - `status_hint: use ${toolName} with {"action":"status","id":"${task.id}"} to inspect it again.` - ) - if (run?.state === 'running' && run.background) { - lines.push( - `message_hint: use ${toolName} with {"action":"message","id":"${task.id}","message":"..."} to send more guidance while it runs.` - ) + meta.push(`hint: use ${toolName} action=message id=${task.id}`) } if (run?.state !== 'running') { - lines.push( - `resume_hint: use ${toolName} with {"action":"run","id":"${task.id}","prompt":"next instruction"} ` + - 'to continue this session. Add "background":true when the work may take a while.' - ) + meta.push(`hint: use ${toolName} action=run id=${task.id} to continue`) } - lines.push('') - if (run?.output?.trim()) { - lines.push('Output:') - lines.push(run.output.trim()) - return lines.join('\n') + return [run.output.trim(), '', ...meta].join('\n') } - lines.push('History:') - lines.push(formatTaskHistory(task.messages)) - return lines.join('\n') + return ['History:', formatTaskHistory(task.messages), '', ...meta].join( + '\n' + ) } function formatTaskHistory(messages: BaseMessage[]) { @@ -1025,7 +1098,7 @@ function formatTaskHistory(messages: BaseMessage[]) { return undefined } - return `${message.getType()}: ${text.length > 280 ? `${text.slice(0, 277)}...` : text}` + return `${message.getType()}: ${text.length > 140 ? `${text.slice(0, 137)}...` : text}` }) .filter((item): item is string => item != null) @@ -1033,7 +1106,7 @@ function formatTaskHistory(messages: BaseMessage[]) { return '(no messages yet)' } - return lines.slice(-6).join('\n') + return lines.slice(-3).join('\n') } function formatTraceText(value: unknown) { @@ -1095,3 +1168,163 @@ function escapeXml(value: string) { .replace(/"/g, '"') .replace(/'/g, ''') } + +export interface AgentTaskDescriptor { + id: string + name: string + description: string +} + +export interface AgentTaskTarget { + agent: ChatLunaAgent + toolMask?: ToolMask +} + +export interface AgentTaskSession { + id: string + agentId: string + agentName: string + conversationId: string + parentConversationId: string + depth: number + maxDepth: number + parentAgent: string + activeRunId?: string + messages: BaseMessage[] + startedAt: number + updatedAt: number +} + +export interface AgentTaskRunTraceEntry { + id: string + type: + | 'prompt' + | 'message' + | 'thought' + | 'tool-call' + | 'tool-result' + | 'output' + | 'error' + at: number + text: string + tool?: string + title?: string + callId?: string +} + +export interface AgentTaskRun { + runId: string + taskId: string + agentId: string + agentName: string + conversationId: string + parentConversationId: string + depth: number + state: 'running' | 'completed' | 'failed' | 'aborted' + background?: boolean + paused?: boolean + startedAt: number + endedAt?: number + lastTool?: string + toolCount: number + turnCount: number + error?: string + output?: string + trace: AgentTaskRunTraceEntry[] +} + +export interface AgentTaskSessionSnapshot { + session?: Session + routing?: { + platform: string + selfId: string + userId: string + username?: string + guildId?: string + channelId?: string + isDirect: boolean + } + bindingKey?: string +} + +export interface AgentTaskFinishedPayload { + run: AgentTaskRun + taskId: string + agentId: string + agentName: string + parentConversationId: string + source: 'chatluna' | 'character' + snapshot?: AgentTaskSessionSnapshot +} + +export interface AgentTaskInput { + action?: 'run' | 'status' | 'list' | 'message' + agent?: string + id?: string + prompt?: string + reason?: string + background?: boolean + message?: string +} + +export interface AgentTaskQueryContext { + session?: Session + source?: 'chatluna' | 'character' +} + +export interface AgentTaskResolveContext extends AgentTaskQueryContext { + conversationId?: string + parent?: SubagentContext + runConfig?: ChatLunaToolRunnable +} + +export interface CreateTaskToolOptions { + list: (ctx: AgentTaskQueryContext) => Awaitable + get: ( + name: string, + ctx: AgentTaskResolveContext + ) => Awaitable + refresh?: () => Awaitable + maxDepth?: number + taskTtl?: number + runTtl?: number + name?: string + onRunFinished?: (payload: AgentTaskFinishedPayload) => Awaitable +} + +declare module 'koishi' { + interface Events { + 'chatluna/agent-task-finished': ( + payload: AgentTaskFinishedPayload + ) => Promise + } +} + +export interface AgentTaskToolRuntime { + buildToolDescription(): string + createTool(): StructuredTool + dispose(): Promise + getRuns(): AgentTaskRun[] + getTasks(): AgentTaskSession[] + getTask(id: string): AgentTaskSession | undefined + stopTask(id: string): Promise + pauseTask(id: string): Promise + resumeTask(id: string): Promise + abortByParentConversation(id: string): Promise + chatTask( + id: string, + prompt: string, + ctx: AgentTaskResolveContext + ): Promise<{ state: 'queued' | AgentTaskRun['state']; output?: string }> + runTask( + input: AgentTaskInput, + runConfig?: ChatLunaToolRunnable + ): Promise +} + +interface ActiveAgentTaskRun { + abort: AbortController + queue: MessageQueue + paused?: boolean + resume?: () => void +} diff --git a/packages/core/src/llm-core/agent/types.ts b/packages/core/src/llm-core/agent/types.ts index 28175cb6e..a0ddb2b78 100644 --- a/packages/core/src/llm-core/agent/types.ts +++ b/packages/core/src/llm-core/agent/types.ts @@ -291,6 +291,7 @@ export interface AgentCallbackEvent { export interface AgentRuntimeConfigurable { messageQueue?: MessageQueue + pauseGate?: (signal?: AbortSignal) => Promise onAgentEvent?: (event: AgentEvent) => Promise | void agentContext?: AgentRunContext } diff --git a/packages/core/src/llm-core/agent/wakeup.ts b/packages/core/src/llm-core/agent/wakeup.ts new file mode 100644 index 000000000..3c48eb146 --- /dev/null +++ b/packages/core/src/llm-core/agent/wakeup.ts @@ -0,0 +1,101 @@ +import { randomUUID } from 'crypto' + +import { HumanMessage } from '@langchain/core/messages' +import { Context, h, Universal } from 'koishi' +import { buildVirtualSession } from 'koishi-plugin-chatluna/utils/virtual_session' +import type { Config } from '../../config' +import { + type AgentTaskFinishedPayload, + formatAgentTaskWakeup +} from './sub-agent' + +export function applyAgentTaskWakeup(ctx: Context, config: Config) { + ctx.on('chatluna/agent-task-finished', async (payload) => { + if (!config.agentTaskAutoWakeup) return + if (payload.run.background !== true) return + if (payload.run.state === 'aborted') return + if (payload.source !== 'chatluna') return + if (payload.parentConversationId.startsWith('subagent:')) return + + const conversation = await ctx.chatluna.conversation.getConversation( + payload.parentConversationId + ) + if (conversation == null) return + + const content = formatAgentTaskWakeup( + payload.taskId, + payload.agentName, + payload.run + ) + const msg = new HumanMessage({ content, name: 'task' }) + + if ( + conversation.chatMode === 'plugin' && + (await ctx.chatluna.conversationRuntime.appendPendingMessage( + payload.parentConversationId, + msg, + conversation.chatMode + )) + ) { + return + } + + const session = restoreSession(ctx, payload) + if (session == null) { + ctx.logger.warn( + 'agent task %s finished but bot %s:%s is offline; result kept until TTL.', + payload.taskId, + payload.snapshot?.routing?.platform, + payload.snapshot?.routing?.selfId + ) + return + } + + const resolved = await ctx.chatluna.conversation.resolveConversation( + session, + { + mode: 'active', + bindingKey: + payload.snapshot?.bindingKey ?? conversation.bindingKey, + conversationId: conversation.id + } + ) + if (resolved.conversation == null) return + + await ctx.chatluna.chatChain.receiveCommand(session, 'chat', { + message: [h.text(content)], + messageId: randomUUID(), + conversation: resolved, + triggerWakeup: { + requestId: randomUUID(), + source: { + kind: 'agent-task', + detail: { + taskId: payload.taskId, + runId: payload.run.runId, + agent: payload.agentName, + state: payload.run.state + } + } + }, + inputMessage: { content, name: 'task' } + }) + }) +} + +function restoreSession(ctx: Context, payload: AgentTaskFinishedPayload) { + const live = payload.snapshot?.session + if (live?.bot?.status === Universal.Status.ONLINE) return live + + const routing = payload.snapshot?.routing + if (routing == null) return undefined + + const bot = ctx.bots[`${routing.platform}:${routing.selfId}`] + if (bot == null || bot.status !== Universal.Status.ONLINE) return undefined + + return buildVirtualSession( + bot, + { ...routing, username: 'task' }, + { message: '', messageName: 'task' } + ) +} diff --git a/packages/core/src/locales/en-US.schema.yml b/packages/core/src/locales/en-US.schema.yml index 39009be36..249afa4d4 100644 --- a/packages/core/src/locales/en-US.schema.yml +++ b/packages/core/src/locales/en-US.schema.yml @@ -27,6 +27,7 @@ $inner: msgCooldown: Set global message cooldown (seconds) to limit adapter calls. messageQueue: Enable message queue. When enabled, if multiple messages are sent and the model has not responded to the initial message, the messages will be cached and merged into one message, and will be sent after waiting for the model response. messageQueueDelay: Set message queue delay time (seconds). Set to 0 to disable delay. When delay is enabled, the system will wait for the specified time before sending messages to the model. If there are unprocessed messages during this period, they will be cached and merged into multiple messages to send to the model. + agentTaskAutoWakeup: Automatically wake up the conversation when a background agent task finishes. Disable to return to manual status checks. showThoughtMessage: Display thinking message in plugin mode or reasoner model. - $desc: Message Rendering diff --git a/packages/core/src/locales/zh-CN.schema.yml b/packages/core/src/locales/zh-CN.schema.yml index adfdbde4e..3a89ddd06 100644 --- a/packages/core/src/locales/zh-CN.schema.yml +++ b/packages/core/src/locales/zh-CN.schema.yml @@ -27,6 +27,7 @@ $inner: msgCooldown: 设置全局消息冷却时间(单位:秒),用于防止适配器被过于频繁地调用。 messageQueue: 设置是否启用消息队列。启用后当发送了多条消息,并且模型未响应初始消息时,会将将消息缓存合并成一条消息,并等待模型响应后再发送。 messageQueueDelay: 设置消息队列的延迟时间(单位:秒)。为 0 则不启用延迟。开启延迟后,会等待指定时间后才发送消息给模型,如果在此期间内存在未处理的消息,则将消息缓存合并成多条消息发送给模型。 + agentTaskAutoWakeup: 后台代理任务完成后自动唤醒所在对话。关闭后回到手动 status 查询模式。 showThoughtMessage: 在使用插件模式或思考模型时,是否显示思考过程。 - $desc: 消息渲染选项 diff --git a/packages/core/src/middlewares/chat/stop_chat.ts b/packages/core/src/middlewares/chat/stop_chat.ts index 1dd2c335e..fab1428db 100644 --- a/packages/core/src/middlewares/chat/stop_chat.ts +++ b/packages/core/src/middlewares/chat/stop_chat.ts @@ -64,8 +64,14 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { if (!status) { context.message = session.text('.no_active_chat') + return ChainMiddlewareRunStatus.STOP } + await ctx.parallel('chatluna/chat-stopped', { + conversationId: conversation.id, + session + }) + return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') diff --git a/packages/core/src/middlewares/conversation/request_conversation.ts b/packages/core/src/middlewares/conversation/request_conversation.ts index 168731425..0a1dfa987 100644 --- a/packages/core/src/middlewares/conversation/request_conversation.ts +++ b/packages/core/src/middlewares/conversation/request_conversation.ts @@ -135,8 +135,14 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { let responseMessage: Message inputMessage.conversationId = conversation.id - inputMessage.name = - session.author?.name ?? session.author?.id ?? session.username + if (wakeup?.source.kind === 'agent-task') { + inputMessage.name = 'task' + } else { + inputMessage.name = + session.author?.name ?? + session.author?.id ?? + session.username + } const requestId = context.options.messageId diff --git a/packages/core/src/services/types.ts b/packages/core/src/services/types.ts index 6ce1e61fe..faca6fa1e 100644 --- a/packages/core/src/services/types.ts +++ b/packages/core/src/services/types.ts @@ -186,6 +186,10 @@ declare module 'koishi' { } interface Events { + 'chatluna/chat-stopped'(payload: { + conversationId: string + session: Session + }): Promise 'chatluna/before-check-sender'(session: Session): Promise 'chatluna/check-passive-trigger'( session: Session, diff --git a/packages/core/src/utils/virtual_session.ts b/packages/core/src/utils/virtual_session.ts new file mode 100644 index 000000000..0e7e6494a --- /dev/null +++ b/packages/core/src/utils/virtual_session.ts @@ -0,0 +1,47 @@ +import { h, type Session, type Universal } from 'koishi' +import { transformMessageContentToElements } from 'koishi-plugin-chatluna/utils/koishi' +import { getMessageContent } from 'koishi-plugin-chatluna/utils/string' + +export interface VirtualSessionRouting { + platform: string + selfId: string + userId: string + username?: string + guildId?: string + channelId?: string + isDirect: boolean +} + +export function buildVirtualSession( + bot: Session['bot'], + routing: VirtualSessionRouting, + action: { + message: Parameters[0] + messageName?: string + } +) { + const event: Partial = { + type: 'message', + platform: routing.platform, + selfId: routing.selfId, + timestamp: Date.now(), + channel: { + id: routing.channelId ?? routing.guildId ?? routing.userId, + type: routing.isDirect ? 1 : 0 + }, + guild: routing.guildId == null ? undefined : { id: routing.guildId }, + user: { + id: routing.userId, + name: routing.username ?? action.messageName ?? 'trigger' + }, + message: { + content: getMessageContent(action.message), + elements: + typeof action.message === 'string' + ? [h.text(action.message)] + : transformMessageContentToElements(action.message) + } + } + + return bot.session(event) +} diff --git a/packages/extension-agent/client/components/computer/computer-page.vue b/packages/extension-agent/client/components/computer/computer-page.vue index c4573477d..8a73167b8 100644 --- a/packages/extension-agent/client/components/computer/computer-page.vue +++ b/packages/extension-agent/client/components/computer/computer-page.vue @@ -54,11 +54,9 @@
-
默认电脑能力后端
+
默认能力后端
- Agent - 会优先使用这里选择的执行环境,建议优先启用隔离后端, - Local 仅在明确知道风险时再打开。 + Agent 会优先使用这里选择的执行环境。推荐先用隔离后端。
@@ -67,17 +65,17 @@ class="provider-select" @update:model-value="updateProvider" > - - - + + +
-
会话自动关闭
+
会话自动释放
- 当会话的空闲时间超过此时间后会自动关闭。 + 会话闲置超过此时间后自动关闭。
@@ -206,7 +204,6 @@ const props = withDefaults( dangerouslySkipPermissions: false, preferredShell: 'auto', scopePath: '', - writableRoots: [], readOnlyRoots: [], denyRoots: [], ignores: [], diff --git a/packages/extension-agent/client/components/computer/config-backends/backend-e2b.vue b/packages/extension-agent/client/components/computer/config-backends/backend-e2b.vue index 92b73ca1b..fac437bc1 100644 --- a/packages/extension-agent/client/components/computer/config-backends/backend-e2b.vue +++ b/packages/extension-agent/client/components/computer/config-backends/backend-e2b.vue @@ -1,7 +1,7 @@ diff --git a/packages/extension-agent/client/components/computer/config-backends/backend-local.vue b/packages/extension-agent/client/components/computer/config-backends/backend-local.vue index 129c14fd9..a5076b89a 100644 --- a/packages/extension-agent/client/components/computer/config-backends/backend-local.vue +++ b/packages/extension-agent/client/components/computer/config-backends/backend-local.vue @@ -2,18 +2,22 @@
本地终端能力很危险
-
它会直接在宿主机执行命令,而不是隔离沙箱。建议默认关闭,只在明确知道风险、且需要访问本地工作区时临时启用。
+
直接访问宿主机文件系统并运行系统命令。模型会以当前用户权限操作。
基础设置
+
+ Linux 未开启“跳过沙箱与权限约束”时需要安装 + bubblewrap (bwrap)。开启后不使用 bwrap。 +
- + @@ -26,10 +30,10 @@ @update:model-value="set('sandboxMode', $event)" > - +
@@ -90,10 +94,9 @@
-
访问边界
+
文件策略
- 路径和 glob - 模式请逐条输入,按回车后会变成标签,修改时更直观,也不容易误删。 + 初始工作目录只是起点,不是访问边界。这里可以排除扫描结果,或单独禁止、只读某些路径。
@@ -120,31 +123,6 @@
-
- - - - - -
-
- 跳过权限检查(危险) + 跳过沙箱与权限约束(危险)
- 启用后将跳过作用域、白名单和高危操作确认。仅在完全信任当前模型时使用。 + 开启后不使用 bwrap,也不做路径保护、命令白名单和高危确认。模型会直接以当前用户权限运行。
@@ -269,7 +247,6 @@ import type { LocalBackendConfig } from '../../../../src/types' type LocalListKey = - | 'writableRoots' | 'readOnlyRoots' | 'denyRoots' | 'ignores' diff --git a/packages/extension-agent/client/components/computer/config-backends/backend-open-terminal.vue b/packages/extension-agent/client/components/computer/config-backends/backend-open-terminal.vue index aae319ebd..0a02af10a 100644 --- a/packages/extension-agent/client/components/computer/config-backends/backend-open-terminal.vue +++ b/packages/extension-agent/client/components/computer/config-backends/backend-open-terminal.vue @@ -1,7 +1,7 @@