import type { FinishReason, LlmMessage, LlmProvider, LlmUsage, ToolCallRequest, ToolChoice, ToolDef } from './provider-base.js';
import type { ToolDispatcher } from './tool-dispatcher.js';
/**
 * Deterministic JSON.stringify with sorted object keys at every level.
 * Used by the stuck-loop detector to derive a stable identity key for
 * tool-call arguments: identical calls hash to identical strings
 * regardless of key insertion order. Tolerates circular references and
 * non-JSON-able values by collapsing them to a placeholder.
 */
export declare function stableStringify(value: unknown, seen?: WeakSet<object>): string;
/**
 * Stable error-type fingerprint of a dispatcher's result text. Returns
 * the first non-empty line trimmed and truncated to 80 chars when
 * isError; empty string otherwise. Used by the stuck-loop detector so
 * "fs_read failed with ENOENT three times" → stuck, but "fs_read
 * returned different bytes three times" → not stuck.
 */
export declare function extractErrorType(isError: boolean, text: string): string;
/** True iff the last 3 entries are the same tool called with identical
 *  args resulting in the identical outcome — the stuck-loop signal. */
export declare function isStuckLoop(reports: Array<Pick<ToolCallReport, 'name' | 'argsKey' | 'errorType'>>): boolean;
/**
 * v1.2.122 — semantic stuck-loop: 3× DIFFERENT tool names but the same
 * salient argument value (query / URL / path / command substring).
 *
 * Catches the failure mode `isStuckLoop` misses by design:
 *   1. native_web_search(query="aapl Q3 earnings call transcript")
 *      → "0 results from duckduckgo"
 *   2. mcp__tavily(query="aapl Q3 earnings call transcript")
 *      → "0 results"
 *   3. native_exec(command="curl … aapl Q3 earnings call transcript …")
 *      → "404"
 *
 * Same intent, three different tool surfaces, all failing. The strict
 * `isStuckLoop` doesn't fire because tool NAMES differ. Without this
 * check the model keeps reshuffling tool roulette until max_iter.
 *
 * Heuristic: extract every string ≥ 8 chars from each call's argsKey
 * (a stable JSON of the call.arguments). If the SAME string appears in
 * all three recent calls AND all three tool names differ → return true.
 *
 * The ≥ 8 char floor filters out short enum-like values ("json",
 * "stop", "auto") that would false-positive across unrelated calls.
 */
export declare function isSemanticStuckLoop(reports: Array<Pick<ToolCallReport, 'name' | 'argsKey'>>): boolean;
/** Pull every string ≥ SALIENT_MIN_LEN out of a stable-JSON arg blob.
 *  Doesn't try to be clever about which JSON path the string came
 *  from — three calls passing the same long string anywhere in their
 *  args is already strong evidence of repeated intent. */
export declare function extractSalientStrings(argsKey: string): Set<string>;
/**
 * Per-tool-call gate. Return 'allow' to dispatch, 'deny' to inject a
 * "user denied" tool result (so the model can react) without running
 * the tool. Sub-PR #4 ships an IM-integrated implementation; tests
 * and intro use a no-op gate (alwaysAllowGate).
 *
 * `call` is the request the LLM produced; `attempt` is the 0-indexed
 * iteration count, useful for "ask once per session" policies.
 */
export type ApprovalGate = (call: ToolCallRequest, attempt: number) => Promise<'allow' | 'deny'>;
/** Default: skip approval entirely. The agent loop is safe-by-default
 *  for tests and the introspection use case; production AgentAdapters
 *  should pass a real gate. */
export declare const alwaysAllowGate: ApprovalGate;
/** Auxiliary finish reasons the agent loop adds on top of the provider's
 *  finishReason vocabulary.
 *
 *  - `stuck_loop` (v1.2.95): the loop terminated early because the model
 *    called the SAME tool with the SAME outcome (error+preview hash)
 *    three iterations in a row — usually means it's stuck re-trying a
 *    failing SQL / fetch / command without changing strategy. Without
 *    this break a stuck model would burn the full
 *    IMHUB_NATIVE_AGENT_MAX_ITER budget; stopping early saves cost AND
 *    gives the user a much more actionable recap. */
/**  - `off_track` (v1.2.98): goal-critic flagged the recent tool chain
 *    as semantically off-target for the user's prompt / active goal.
 *    Different from stuck_loop (mechanical repeat) — this catches the
 *    "different calls but still pointless" failure mode. */
export type LoopFinishReason = FinishReason | 'max_iterations' | 'aborted' | 'stuck_loop' | 'off_track';
export interface AgentLoopInput {
    provider: LlmProvider;
    /** Optional system prompt prepended ONCE; never re-issued across
     *  iterations. If `messages[0]` is already a system message, this
     *  field is ignored (mirrors LlmProvider.chat semantics). */
    systemPrompt?: string;
    /** Conversation so far. The loop appends to a local copy; the caller's
     *  array is NOT mutated. */
    messages: LlmMessage[];
    /** Tools the LLM may call. Empty / undefined disables tool use even
     *  if the provider advertises it. */
    tools?: ToolDef[];
    /** Hint forwarded to provider.chat (see provider-base ToolChoice). */
    toolChoice?: ToolChoice;
    /** Hard cap on iterations. Default 20 — high enough for non-trivial
     *  research, low enough that a runaway loop is bounded. */
    maxIterations?: number;
    /** Hard wall-clock cap (ms). Default 5 min. */
    timeoutMs?: number;
    /** Outer abort signal; merged with timeoutMs into the provider call. */
    signal?: AbortSignal;
    /** Per-call provider knobs (model override / temperature / etc.). */
    callOptions?: {
        model?: string;
        temperature?: number;
        maxTokens?: number;
    };
    /** Routes tool calls to executors. Required when `tools` is non-empty;
     *  the loop throws on missing dispatcher to surface misconfig early. */
    dispatch?: ToolDispatcher;
    /** Pre-tool gate. Defaults to alwaysAllowGate. */
    approve?: ApprovalGate;
    /** Audit context — written into per-iteration invocations rows. The
     *  agent name should distinguish native vs cli (e.g. 'llm:native-chat'). */
    audit?: {
        agent: string;
        intent?: string;
        userId?: string;
        platform?: string;
        traceId?: string;
    };
    /** v1.2.86 — when set, the loop checks goals.db for an active goal on
     *  this thread and extends the wall timeout (default 3×, cap 6 h).
     *  Supplied by AgentAdapter / cli.ts when invoking the native agent
     *  from a real IM run; tests typically leave it undefined and get the
     *  un-boosted timeout. */
    goalCtx?: {
        platform: string;
        channelId: string;
        threadId: string;
    };
    /** v1.2.98 — anchors handed to the goal-critic when it fires.
     *  `prompt` is the user's current-turn input string; `goalTitle` +
     *  `goalBody` come from goals.getActiveGoal for this thread (caller
     *  fetches once; loop just relays). Either may be empty — critic
     *  refuses to judge if both are. Leaving criticAnchor unset (or
     *  setting IMHUB_NATIVE_CRITIC=off) disables the critic for this
     *  invocation. */
    criticAnchor?: {
        prompt?: string;
        goalTitle?: string;
        goalBody?: string;
    };
    /** v1.2.109 — names of tools the caller declares parallel-safe (no
     *  shared state, no side effects, order-independent). When the model
     *  returns multiple tool_calls in one iteration, declared-safe calls
     *  run concurrently via Promise.allSettled; everything else (and any
     *  tool not in this set) keeps the legacy serial behaviour, with a
     *  barrier drain before each serial dispatch so serial calls observe
     *  a settled world. Emission order to history + toolCalls is always
     *  the original call order (tool_call_id ↔ tool_result alignment).
     *  Leave undefined → loop is fully serial (no behaviour change vs
     *  v1.2.108). */
    parallelSafeTools?: ReadonlySet<string>;
    /** v1.2.112 — opt into streaming the provider response so the iter
     *  preserves partial assistant text on abort instead of dropping
     *  everything when the 30-min IM hard timeout fires mid-response.
     *  When true the loop uses `provider.chatStream()`, accumulates text
     *  / tool_calls / reasoning, and on abort returns the partial text
     *  with finishReason='aborted'. Falls back to buffered `chat()` on
     *  any provider that doesn't implement streaming. Default false to
     *  preserve v1.2.111 behaviour. Env `IMHUB_AGENT_LOOP_STREAM=off`
     *  is a process-wide kill switch even when this is true (so ops can
     *  flip it off without code changes). */
    streamPartialText?: boolean;
    /** v1.2.112 — fires with each streamed text delta (after backpressure-
     *  free accumulation). Useful for "agent is thinking… 100/250/...
     *  chars produced" heartbeats or future SSE push to the IM client.
     *  Only fires when `streamPartialText` is effectively true. */
    onPartialText?: (delta: string, totalSoFar: string) => void;
    /** Lifecycle hooks for caller-side observability (heartbeats, progress
     *  push, custom logging). All hooks are best-effort: thrown errors are
     *  swallowed so a faulty observer cannot break the loop. */
    hooks?: {
        /** Fires right before a tool's dispatcher is invoked (after the
         *  approval gate has allowed the call). */
        onToolStart?: (call: ToolCallRequest) => void;
        /** Fires right after the dispatcher returns or throws. */
        onToolEnd?: (call: ToolCallRequest, summary: {
            isError: boolean;
            durationMs: number;
        }) => void;
    };
}
export interface ToolCallReport {
    name: string;
    /** `'allow' | 'deny'` from the approval gate before dispatch. */
    decision: 'allow' | 'deny';
    /** True iff the dispatcher reported an error (or denied). */
    isError: boolean;
    /** Tag from the dispatcher (e.g. 'mcp:filesystem' / 'unknown'). */
    source: string;
    durationMs: number;
    /** First 200 chars of the tool result text. Useful for debug /
     *  `/heartbeat run-now` style introspection; longer payloads aren't
     *  retained to keep memory + log size bounded. */
    preview: string;
    /** v1.2.108 — stable JSON of the tool call's arguments (sorted keys),
     *  used by the stuck-loop detector. Identical calls hash to identical
     *  strings; model varying timestamps/UUIDs in the result no longer
     *  hides a stuck loop where the model keeps re-calling with the same
     *  args. See `stableStringify`. */
    argsKey: string;
    /** v1.2.108 — short fingerprint of the error (first non-empty line of
     *  the result, ≤80 chars) when isError; empty string otherwise. Lets
     *  the stuck-loop detector distinguish "same failure repeated" from
     *  "same args returning different bytes". See `extractErrorType`. */
    errorType: string;
}
export interface AgentLoopResult {
    /** Final assistant text the caller should show the user. May be the
     *  empty string for finishReason='max_iterations' / 'aborted' if the
     *  model never emitted assistant text before being cut off. */
    text: string;
    /** Number of provider.chat() calls (0 only when input.messages is
     *  empty AND the loop never ran). */
    iterations: number;
    /** Reports for every tool call the loop dispatched (or denied). */
    toolCalls: ToolCallReport[];
    /** Aggregated usage across iterations. costUsd is summed when present. */
    usage: LlmUsage;
    /** Why the loop stopped. */
    finishReason: LoopFinishReason;
    /** Provider-side error message when finishReason='error'. */
    error?: string;
}
/**
 * Run the loop. ALL exceptions are caught and converted to a result
 * with finishReason='error' — the caller (IM AgentAdapter) wants to
 * surface failures to the user, not crash agim.
 */
export declare function runAgentLoop(input: AgentLoopInput): Promise<AgentLoopResult>;
//# sourceMappingURL=agent-loop.d.ts.map