import type { Context } from "@deepseek-ai/cordis"; import { type Session, type SessionEvent } from "@deepseek-ai/dsh-session"; import { type ModelSelection } from "@deepseek-ai/dsh-agent"; import type { ChannelAdapter, InboundMessage, OutboundTarget } from "./types.js"; import { type NotifyLevel } from "./stream.js"; import { type MenuId } from "./menus.js"; import { type MenuHost } from "./menu-controller.js"; import type { BindingStore } from "./binding.js"; import { type Language, type Messages } from "./i18n.js"; import { type ReminderStore } from "./scheduler.js"; export interface ConnectConfig { /** Agent preset id composed into each session; `undefined` = roster default. */ agentPreset?: string; /** Absolute working directory for each bound agent; defaults to process cwd. */ workDir?: string; /** Optional workspace directories offered by the `/dir` chooser. */ workspaces?: string[]; /** Vision-capable model used to describe images when the main model can't. */ visionModel?: { provider: string; model: string; }; /** User-facing message language: `zh` (default) or `en`. */ language?: Language; /** Fallback sender allowlist (open ids / user ids) for channels without their own. */ allowUsers?: string[]; /** Fallback chat allowlist for channels without their own. */ allowChats?: string[]; /** * Per-channel access control, keyed by channel id. A channel listed here uses * these lists *instead of* the top-level `allowUsers`/`allowChats`, because * identifiers are channel-specific and one global list cannot be right for two * channels at once. */ channelAccess?: Record; stateDir?: string; /** Automatically create Web mirror for new sessions (default: true). */ autoMirror?: boolean; /** Liveness heartbeat interval ms for the streaming card; 0 disables it (default: 60000). */ streamHeartbeatMs?: number; /** Streaming reply detail. Default 'result' (final answer only) keeps chat cards short. */ notifyLevel?: NotifyLevel; /** Proactive progress-notice interval ms: when a turn goes silent for this long, a standalone status card is sent (default: 300000 = 5 min; 0 disables). */ progressTimeoutMs?: number; /** Compact the session automatically once context usage crosses `autoCompactThresholdPct` (default: false). */ autoCompact?: boolean; /** Context-window usage percentage that triggers `autoCompact` (default: 80). */ autoCompactThresholdPct?: number; /** * How long a streaming card can be trusted to still exist (ms). * * Not user configuration — it is Feishu's ten-minute card lifetime. It lives here only so * a test can shorten it: the rule it gates applies to turns longer than ten minutes, and * a test that has to hold a turn open for ten real minutes would never be written. */ cardLifetimeMs?: number; } export interface ResolvedConnectConfig { agentPreset?: string; workDir?: string; workspaces: string[]; visionModel?: { provider: string; model: string; }; language: Language; allowUsers: string[]; allowChats: string[]; /** Resolved per-channel access control; a missing channel falls back to the two above. */ channelAccess: Record; stateDir?: string; autoMirror: boolean; streamHeartbeatMs: number; notifyLevel: NotifyLevel; progressTimeoutMs: number; autoCompact: boolean; autoCompactThresholdPct: number; /** Test-only override of the streaming-card lifetime. See the ConnectConfig field. */ cardLifetimeMs?: number; } export declare function resolveConnectConfig(config: ConnectConfig): ResolvedConnectConfig; export declare class AgentRunner implements MenuHost { readonly ctx: Context; private readonly config; readonly channel: string; readonly chatKey: string; private readonly chatType; readonly adapter: ChannelAdapter; readonly bindings: BindingStore; private readonly adapters?; /** * Route a replayed queued message back into the runner of its own channel * (wired by ConnectService). Without it, replayed messages would run in * whichever runner happened to release the lock. */ private readonly requeue?; /** Persistent chat-level reminders (`/remind`), wired by ConnectService. */ private readonly reminders?; private readonly queue; private running; /** * Is a turn being driven by this runner right now? * * Used to route a question to the surface the user is actually typing in: a session can * be reachable from a chat and from the Web GUI mirror at once, and only the busy one is * where they are looking. */ get isDrivingTurn(): boolean; /** Set by `dispose()`; makes the runner permanently inert. */ private disposed; private agent?; private handle?; private turn?; workDir: string; language: Language; notifyLevel: NotifyLevel; progressTimeoutMs: number; /** Compact automatically once context usage crosses `autoCompactThresholdPct`. */ autoCompact: boolean; /** Context-window usage percentage that triggers auto-compaction. */ autoCompactThresholdPct: number; t: Messages; private readonly menu; constructor(ctx: Context, config: ResolvedConnectConfig, channel: string, chatKey: string, chatType: "p2p" | "group", adapter: ChannelAdapter, bindings: BindingStore, adapters?: Map | undefined, /** * Route a replayed queued message back into the runner of its own channel * (wired by ConnectService). Without it, replayed messages would run in * whichever runner happened to release the lock. */ requeue?: ((msg: InboundMessage) => void) | undefined, /** Persistent chat-level reminders (`/remind`), wired by ConnectService. */ reminders?: ReminderStore | undefined); menuTitle(menuId: MenuId): string; rootMenuSections(): readonly { title: string; ids: readonly string[]; columnsPerRow?: number; }[]; private reasonLabel; /** Prefer the user's first DSH workspace over the process cwd as default. */ private resolveDefaultWorkDir; /** * Tear the runner down for good — the counterpoint to `enqueue`. * * Called when the runner's channel adapter is deactivated (a settings change * removed that channel from `channels[]`) or the service shuts down. The * runner is inert afterwards: `enqueue` drops work and the drain loop stops * before its next turn. `ConnectService` deletes its map entry, so a later * message builds a fresh runner instead of reviving this one. * * `queuedMessages` is cleared rather than replayed: those messages belong to * a channel that no longer has an adapter to answer on, so replaying them * would run agent turns whose output has nowhere to go. */ dispose(): Promise; enqueue(msg: InboundMessage): void; /** Route one live session event to the active turn's chunk queue. */ onSessionEvent(session: Session, event: SessionEvent): void; /** * Show the subtask list, and keep it current in place. * * The first write posts the list; every later write edits that same message. Editing * rather than re-posting is the whole point — a plan that arrives as a new bubble every * time a step completes buries the conversation, and the user asked for the list to be * *updated*, not repeated. * * Runs at every notification level, for the same reason the progress reminder does: the * plan is not live activity chatter, it is the shape of the work, and a user who chose * a quiet level chose to stop hearing about individual tool calls — not to be left * guessing what the task actually consists of. */ /** * Post the progress reminder, or rewrite the one already on screen. * * Reported from a turn left running overnight: 「一晚上会堆积无数的提醒」. The watchdog was * appending a fresh message every interval, so a 13.6-hour turn produced over 160 * near-identical bubbles — each one repeating the elapsed time and the current milestone. * The information in the last one supersedes every earlier one, which makes the earlier * ones pure noise. * * The first notice posts and remembers its id; every later one rewrites that message. A * channel that cannot edit text keeps posting, which is the old behaviour rather than a * regression — and if the edit fails the id is dropped so the next notice starts a fresh * message instead of editing something that no longer exists. */ private publishProgressNotice; /** * The list body plus the time it was last accurate. * * Measured on a real session: the agent calls `todo_write` rarely — one stretch had 224 * tool calls between writes, with the card showing "0 done" throughout. The card was not * wrong, it was *old*: it can only reflect what the agent last reported, and the agent * does not re-report after every step. The user read that as "the progress stopped * updating" (「实际可能已经到了第5步」), which is the reasonable reading of a live-looking * list that is an hour out of date. * * So the card says how old it is. What it must NOT do is guess: inferring progress from * tool activity would mean inventing which step a tool call belongs to, and a plan that * states a status the agent never reported is worse than one that admits its age. */ private subtaskBody; private publishSubtasks; /** * Proactive context-high nudge: when the turn's observed context usage * crosses the compaction threshold, ask the user (once per turn) whether to * compact now, instead of waiting until the turn ends to report it. */ private maybeNudgeContext; private target; /** Completion-card target: in groups, @ the requester so they notice the result. */ private taskTarget; /** Send the one-time welcome card on a chat's first message (persisted marker). */ private maybeSendWelcome; /** Pick the localized advice text for a failed turn. */ private errorAdvice; /** Present a destructive-action confirmation; true only when the user confirms. */ confirmAction(target: OutboundTarget, promptText: string, messageId?: string): Promise; private drain; private runTurn; private ensureAgent; /** * Attach a session to the DSH workspace matching its work directory so the * Web GUI groups it under the same workspace the Feishu side uses. * Best-effort: without a workspace registry this is a no-op. */ private attachToWorkspace; /** * Historical sessions for the CURRENT work directory: this chat's binding * records (filtered by workDir), then any other session the DSH workspace * registry attaches to the directory (e.g. Web-created or older-chat * sessions). Titles come from the binding record when known, otherwise from * the host's session title service. Sorted newest first. */ collectWorkdirSessions(): Promise<{ sessionId: string; title: string; }[]>; /** * Session ids the DSH workspace registry attaches to a work directory. * Best-effort: without a workspace registry this returns `[]`. */ private workspaceSessionIds; /** Latest display title of a session via the host query service, or undefined. */ private sessionTitleOf; private get agents(); private get sessions(); defaultSelection(): ModelSelection; /** * Compose the preset-only agent setup. * * Deliberately does NOT install a static model selection on the agent. The * DSH Web GUI switches models through the host api-proxy's session-scoped * selection (`selectionFor` + `installModelSelection`), which is installed * lazily when a session's model directory is loaded and wins the * `agent/request` waterfall. A static selection captured here at create / * resume time would take precedence over that and silently pin every request * to the default model captured at composition — so a model the user picks * in the Web GUI would appear to succeed (the seat and `session.models` * report it) while the actual LLM requests keep using the old default. * The default model still seeds the agent through `agentOptions` in * `ensureAgent`, which `buildRequest` uses as its fallback route. * * Preset resolution is best-effort — see {@link resolvePresetId}. This method * never throws for a preset reason, so no configuration id can stop a turn * from composing an agent at all. */ private composeSetup; /** * Resolve the preset to compose, degrading instead of aborting the turn. * * The host looks a preset up by id, so one stale id — `agentPresets.default` * naming a preset this install does not ship, or a preset directory edited * into an unreadable state — used to throw straight out of `composeSetup`. * That happens before an agent exists, so *every* turn of every bound chat * died with the raw host error and nothing in the session log to explain it. * Resolution is therefore best-effort: the configured id is tried, then * {@link FALLBACK_PRESET_ID}, and when neither composes the agent is built * without a preset — the same shape a host with no `agentPresets` service * already produced — so the turn still runs. */ private resolvePresetId; /** * Resolve `id`, reporting why it is unusable instead of throwing. * * A preset that resolves but reports `broken` counts as unusable: the host's * mounting paths refuse those, so composing one would only move this failure * to `mount`, after the log line that would have explained it. * * `undefined` id means "the roster default", which is what `resolve` does * with it — the default is as fallible as an explicit id, so it is routed * through the same recovery. */ private tryPreset; /** * The id to retry with: {@link FALLBACK_PRESET_ID} while the roster still * offers it, otherwise the roster's first mountable row. * * Never throws — the caller is already handling a failure, and a roster that * cannot be listed should still leave the hardcoded id to be tried. */ private fallbackPresetId; /** Agents this runner has already attached a `session/event` listener to. */ private readonly watchedAgents; private watchAgent; private driveAgent; /** * Build the user content for one inbound message. When the message carries * images, either pass them directly to the main model (if it supports * vision) or run a vision sub-task with a capable model and inject its * description as text — so a text-only main model never stalls on images. */ private buildUserContent; /** Copy images into `/.dsh-connect-images` so the agent's tools can reach them. */ private stageImages; /** Copy non-image attachments into `/.dsh-connect-files` so the agent's tools can reach them. */ private stageFiles; private imageBlocks; private describeImages; private modelSupportsVision; private findVisionModel; private log; private warn; /** * Send a task-end card and report a delivery failure instead of discarding it. * * The catch used to be a bare `undefined`, which made a dropped card and a card * with nothing to say indistinguishable. It is neither: this card is the * durable record of the turn — the result text and the token/duration summary — * and the streaming card it replaces is meanwhile stuck on its last frame. A * silent failure here leaves the user watching a frozen "running" indicator * with no reason to think anything went wrong. * * The log is the only report available: the user-facing channel is precisely * what just failed, so there is nothing to send the notice on. */ private sendTaskCard; private readTodos; private touchBinding; private sendSummary; /** Context-window usage at or above this percentage suggests compaction. */ /** * How long a streaming card can be trusted to still exist. * * Feishu closes a streaming card ten minutes after it is created — stated three times in * the Lark SDK's own comments ("Feishu auto-closes after 10min"). Treated as a hard * boundary here because edits to a closed card are accepted by the API and then simply * not shown, which is the worst failure mode available: no error, no log, no update. * * The value is deliberately the conservative reading of the documented figure. */ private static readonly CARD_LIFETIME_MS; /** * The card lifetime this runner uses. * * Overridable so the behaviour can be tested without holding a turn open for ten real * minutes — a rule that only shows up on long turns is exactly the kind that goes * untested, and this one already shipped broken once. The override is not exposed as * user configuration: it is a Feishu platform constant, not a preference. */ private readonly cardLifetimeMs; private static readonly COMPACT_THRESHOLD_PCT; /** * Send a compact task-end card: the model used, input/output tokens, elapsed * time, and whether compaction is advisable given context-window usage. */ private sendTurnStats; private handleCommand; private disposeAgent; /** * `/ps ` — append a note to the running task. Uses the agent's `steer` * inbox: a running driver consumes the note at its next step boundary (so it * steers the in-flight task), and an idle driver starts a new turn with it. */ private handleAppend; /** * `/remind