Module: PWN::AI::Agent::Loop

Defined in:
lib/pwn/ai/agent/loop.rb

Overview

The agent conversation loop:

build system prompt → call LLM with tools → if tool_calls: dispatch,
append role:'tool' results, loop → else: return text.

This replaces the regex-ReAct in PWN::Plugins::REPL :pwn_ai_hook with native function-calling. State (memory, skills, sessions) is all externalised — Loop.run is stateless aside from the messages array it builds.

NEGATIVE-FEEDBACK CLOSURE

Loop.run is where "learn from mistakes, don't repeat them" is actually enforced. On EVERY failed dispatch it:

1. Records the (tool, normalised_error) fingerprint into
 PWN::AI::Agent::Mistakes with a PERSISTENT cross-session count.
2. Reads that count back and, if it OR the in-turn count reaches
 REPEAT_THRESHOLD, prepends a hard "REPEATED FAILURE — change
 approach" guard to the tool result the model sees next.
3. Appends Mistakes.correction_hint (seen N×, sig, KNOWN FIX: …)
 so a previously-discovered fix is handed straight back to the
 model on the FIRST recurrence in a new session — it does not
 have to fail 3× again to re-learn what it already knew.

PromptBuilder.mistakes_block re-injects the top open mistakes and top known fixes into the system prompt of every future turn.

LOCAL-MODEL SCAFFOLDING

When the active engine is :ollama (or the corresponding :agent flags are set) Loop.run additionally:

* threads request → PromptBuilder for relevance-ranked MEMORY,
* threads request → Registry.definitions(relevance:) for a slimmed
tool set (:tool_router),
* splices Learning.exemplars_for(request:) between system and user
as few-shot behaviour retrieval,
* runs a plan-then-act pre-pass (:plan_first) so the model
externalises a tool plan before its first dispatch,
* escalates to a frontier persona for a 3-line corrective hint
once ≥ ESCALATE_AFTER_FAILS in-turn failures accumulate
(:escalation_persona) — the local model still produces the final
answer so Learning/Metrics stay attributed to :ollama.

Constant Summary collapse

DEFAULT_MAX_ITERS =
777
ESCALATE_AFTER_FAILS =
4
BUDGET_HARD_STOP_FAILS =

P17 — when empty_final / known thrash shapes dominate, stop before burning the full ollama cap so the corpus is not pure terminal failure.

8
BUDGET_EMPTY_FINAL_STOP =
3
ENGINE_MODS =
{
  openai: 'PWN::AI::OpenAI',
  grok: 'PWN::AI::Grok',
  ollama: 'PWN::AI::Ollama',
  openwebui: 'PWN::AI::OpenWebUI',
  anthropic: 'PWN::AI::Anthropic',
  gemini: 'PWN::AI::Gemini'
}.freeze
HOT_WINDOW_SECS =

P17 — true when RECENT unresolved agent_loop / assistant_answer budget fingerprints dominate Mistakes.top. Sliding window + auto-cool so the loop's own exhaust-path Mistakes.record cannot permanently latch hot (scar 8ec3303ed69e self-latch). Do NOT deepen caps; cool the detector.

48 * 3600
HOT_COOL_MAX_RECENT =

<=1 budget hit in window => cooled (not hot)

1
PARK_COOL_SECS =

P17 rate-based cool/park for permanent budget scars (8ec3303ed69e). Leaves scar open but parks it so it stops dominating Mistakes.top. Resolve is rate-based only (external / after multi-day cool) — never because a guard patch landed. PARK_COOL_SECS (24h) is intentionally shorter than HOT_WINDOW (48h): once hot?=false, a single cooled scar must not keep owning Mistakes.top for another full day.

24 * 3600
PRIVILEGED_TOOLSETS =
%w[cron swarm].freeze
SNAPSHOT_STALE_SECS =
6 * 3600
INCOMPLETE_FINAL_RX =

P28 — incomplete / handoff finals: model emitted text-only before the goal was done ("shall I proceed?", "next step:", "want me to…"). Loop.run treats no-tool_calls as FINAL; this detector lets us refuse that handoff and keep the tool loop alive for multi-step autonomy.

Local/thinking models (gemma/Qwen abliterated etc.) often emit a monologue that NARRATES the next tool ("Wait, let's try hping3…") without producing native tool_calls or shell(...). Treat that as incomplete too so the loop re-pressures tools instead of FINAL.

/
  \b(shall\s+i|should\s+i|may\s+i|can\s+i|want\s+me\s+to|do\s+you\s+want\s+me|
     next\s+single\s+step|next\s+step\s*:|awaiting\s+your\s+(ok|approval|go-ahead|confirmation)|
     if\s+you(?:'d|\s+would)\s+like\s+me\s+to|say\s+the\s+word|confirm\s+(before|and\s+i)|
     ready\s+to\s+proceed|ok\s+to\s+(proceed|continue|apply)|proceed\?|
     continue\?|before\s+i\s+(apply|change|run|continue|proceed)|
     once\s+you\s+(confirm|approve)|let\s+me\s+know\s+if|
     i(?:'ll|\s+will)\s+wait\b|waiting\s+for\s+(your\s+)?(go|ok|approval|confirmation)
  )\b
/ix
MONOLOGUE_TOOL_INTENT_RX =

Narrated-intent monologue without a structured tool call. Distinct from INCOMPLETE_FINAL_RX (polite handoff to the human).

/
  \b(
    wait[,\s]+let'?s\s+try|
    let'?s\s+try\s+(one|to|again|hping|nmap|ping|sudo|shell|running|checking)|
    i\s+(?:will|'ll)\s+(?:just\s+)?(?:try|run|check|probe|scan|use)\b|
    actually,?\s+i\s+will\b|
    one\s+more\s+thing\b|
    if\s+it\s+fails\b.{0,80}\bthen\s+we\s+can\b|
    verification\s+complete\b|
    report\s+that\s+(?:the\s+)?verification\s+failed\b
  )
/ix
HOWTO_RX =

Request intent for routing (how-to vs act/recon vs pure recall/greeting). Local models thrash when pure explanation/recall/greeting asks are force-planned into multi-step host probes or multi-tool session archaeology. :howto → answer with explanation only (no plan_first / no live recon). :recall → prior-turn / vague memory cue; cheap path only. :greeting → short hello / light smalltalk; deterministic ack, no tools. :recon_act → live discovery; requires explicit authorization language. :act → general agent work with tools.

/
  \b(
    how\s+to|how\s+do\s+i|how\s+can\s+i|how\s+would\s+i|how\s+does\s+one|
    what\s+is\s+the\s+(?:syntax|command|usage|flag|option)|
    explain\s+how|show\s+me\s+how|examples?\s+of\s+using|
    manual\s+for|usage\s+of|syntax\s+for|man\s+page
  )\b
/ix
RECALL_RX =

Pure prior-turn recall — must never enter plan_first / multi-tool loops. Covers both "what did I just say?" (user) and "how did you respond?" / "what did you just say?" (assistant) so last-turn injection is used.

/
  \A\s*(
    what\s+did\s+i\s+(just\s+)?say\??|
    what\s+did\s+i\s+(just\s+)?(?:ask|type|write|request)\??|
    what\s+was\s+my\s+last\s+(?:request|message|question|prompt|turn)\??|
    what\s+was\s+(?:the\s+)?(?:previous|prior|last)\s+(?:thing\s+i\s+said|request|message|turn)\??|
    remind\s+me\s+what\s+i\s+(?:just\s+)?(?:said|asked)\??|
    repeat\s+(?:my\s+)?(?:last|previous)\s+(?:request|message)\??|
    say\s+that\s+again\??|
    recollection\s+test\??|
    memory\s+recall\s+test\??|
    how\s+did\s+you\s+respond(?:\s+to\s+what\s+i\s+(?:just\s+)?(?:said|asked))?\??|
    how\s+did\s+you\s+(?:just\s+)?(?:answer|reply)(?:\s+to\s+(?:me|that|my\s+last))?\??|
    what\s+(?:was|is)\s+your\s+(?:last|previous|prior)\s+(?:answer|response|reply)\??|
    what\s+did\s+you\s+(?:just\s+)?(?:say|answer|reply|respond)\??|
    remind\s+me\s+what\s+you\s+(?:just\s+)?(?:said|answered|replied)\??|
    repeat\s+your\s+(?:last|previous)\s+(?:answer|response|reply)\??
  )\s*\z
/ix
VAGUE_MEMORY_RX =

Broader "use your memory / prior context" cues. Still cheap: inject last turn + at most one memory_recall; never multi-step plans.

/
  \b(
    what\s+did\s+i\s+(just\s+)?(?:say|ask|type|request)|
    what\s+was\s+my\s+last|
    how\s+did\s+you\s+respond|
    what\s+did\s+you\s+(?:just\s+)?(?:say|answer|reply|respond)|
    what\s+(?:was|is)\s+your\s+(?:last|previous|prior)\s+(?:answer|response|reply)|
    (?:without\s+looking\s+up).{0,40}(?:session|discussing|talking)|
    (?:from\s+)?(?:memory|context|earlier|previously|prior\s+turn)|
    (?:do\s+you\s+)?remember\s+what\s+(?:i|you)|
    recall\s+(?:what|my|your|the\s+last)|
    last\s+thing\s+(?:i|you)\s+said
  )\b
/ix
GREETING_RX =

Pure greeting / light smalltalk — never full :act tool loop. Anchored short forms only so "hi, please scan X" stays :act/:recon_act. Do NOT echo weather or invent social filler; answer_greeting is fixed.

/
  \A\s*(
    (?:hi|hello|howdy|hey|yo|sup|hiya|greetings)(?:\s*[.!?]*)?
    (?:\s*,?\s*(?:there|all|folks|team|everyone|y'?all))?
    |
    good\s+(?:morning|afternoon|evening|day|night)(?:\s*[.!?]*)?
    |
    (?:hi|hello|howdy|hey)(?:\s*[.!?*,]*)?\s+
    (?:it'?s|its|it\s+is)\s+
    (?:cloudy|sunny|rainy|raining|foggy|windy|stormy|nice|cold|hot|warm|
       beautiful|gloomy|overcast|clear|chilly|humid|snow(?:ing|y)?)
    (?:\s+out(?:\s+there)?)?(?:\s*[.!?]*)?
    |
    (?:hi|hello|howdy|hey)(?:\s*[.!?*,]*)?\s+
    (?:the\s+weather\s+is\s+\w+|what'?s\s+up|how\s+are\s+you|
       how'?s\s+it\s+going|how\s+goes\s+it)
    (?:\s*[.!?]*)?
  )\s*\z
/ix
LIVE_RECON_RX =
/
  \b(
    (?:find|discover|enumerate|scan|sweep|probe|map)\s+
    (?:live\s+)?(?:hosts?|ips?|targets?|subnet|network|range)|
    live\s+hosts?\s+(?:can\s+you\s+)?find|
    what\s+live\s+hosts|
    ping\s+sweep\s+(?:of\s+)?(?:this|the|my)\s+
    |(?:run|do|perform)\s+(?:a\s+)?(?:ping\s+)?sweep
    |scan\s+(?:this|the|my)\s+(?:subnet|network|lan|range)
  )\b
/ix
AUTH_SCOPE_RX =
/
  \b(
    (?:in[-\s]?scope|authorized|authorised|engagement|written\s+permission|
       bug\s*bounty|explicit(?:ly)?\s+allowed|lab\s+only|my\s+lab|
       roe\b|rules?\s+of\s+engagement|i\s+own\s+this|owned\s+by\s+me|
       permission\s+to\s+(?:scan|test|probe)|scope:\s*\S+)
  )\b
/ix

Class Method Summary collapse

Class Method Details

.authorsObject

Author(s)

0day Inc. support@0dayinc.com



2304
2305
2306
# File 'lib/pwn/ai/agent/loop.rb', line 2304

public_class_method def self.authors
  "AUTHOR(S):\n  0day Inc. <support@0dayinc.com>\n"
end

.helpObject

Display Usage for this Module



2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
# File 'lib/pwn/ai/agent/loop.rb', line 2310

public_class_method def self.help
  puts <<~USAGE
    USAGE:
      final = PWN::AI::Agent::Loop.run(
        request: 'what does `id` return on this host?',
        session_id: PWN::Sessions.create[:id],
        enabled_toolsets: %w[terminal pwn memory skills],
        on_tool: ->(name, args, result) { puts "→ \#{name}: \#{result[0,1_024]}" },
        system_role_content: 'You are a helpful assistant that can call tools to answer questions.'
      )
      # Live task summaries (default ON): BEFORE each tool *collection*,
      # on_tool('task', high_level_brief, '') — one-to-many with real tools.
      # Task lines never carry a result payload (no result row in the TUI).
      # so repl.rb prints name=task with arg_preview=summary. Also coalesce bursts into
      # via on_tool only: [ ts → pwn-ai → task ] <brief> (no [pwn-ai/task] prefix)
      # Toggle via PWN::Env[:ai][:agent]:
      #   task_summary: true|false
      #   task_summary_every: 5          # emit every N tools
      #   task_summary_interval_s: 8.0   # or every N seconds
      #   task_summary_verbose: false

      Supported engines: #{ENGINE_MODS.keys.join(', ')}
      Set PWN::Env[:ai][:active] to choose; PWN::Env[:ai][:agent][:max_iters] to bound.

      Intent routing (all engines; critical for ollama/openwebui):
        how-to / usage questions → text-only explanation (no tools, no plan_first)
        pure prior-turn recall ("what did I just say?") → answer_recall (no tools)
        pure greeting / light smalltalk → answer_greeting (no tools, no weather echo)
        live subnet sweeps without scope language → refuse
        :recon_authorized    - Boolean session flag to allow raw-socket / sweep tools
      Local-model scaffolding (PWN::Env[:ai][:agent]):
        :plan_first          - Boolean, plan-then-act pre-pass (default: local engine :ollama/:openwebui)
        :tool_router         - Boolean/nil, slim Registry.definitions (nil=auto on for ollama)
        :escalation_persona  - Swarm persona name for frontier corrective hints when stuck
        :critic              - S3 constitutional critic before every final (Boolean)
        :red_team_plan       - S4 adversarial plan review after plan_first (Boolean)
        :counterfactual      - S2 A/B branch on REPEAT_THRESHOLD → DPO pair (Boolean)
        :hindsight           - C3 HER-relabel failures (Boolean, default true)
        :policy              - R5 live tabular Q / REINFORCE (Boolean, default true; advisory only)
        :verify_as_reward    - E3 ground every final via extro_verify (Boolean)

      P28 autonomy: incomplete-final detector refuses mid-goal handoffs;
      W3 overconf max_iters_cap is 120 on remote engines (8 on ollama).
      P17 budget-hot caps max_iters to 24 on ollama and 75 on remote engines
      so long multi-step goals keep a usable runway while thrash is cooled.

      #{self}.authors
  USAGE
end

.ollama_wire_messages(opts = {}) ⇒ Object

Supported Method Parameters

wire = PWN::AI::Agent::Loop.ollama_wire_messages( messages: 'required - in-memory OpenAI-ish messages (may have String args)' )

Returns a deep-copied array safe for Ollama / Open WebUI ollama/api/chat:

  • parses JSON-string function.arguments into Hash/Array objects
  • coerces nil assistant content to '' when tool_calls present (Open WebUI GenerateChatCompletionForm rejects content:null alone)
  • drops _native_content / _text_tool_coerced / thinking private keys
  • stringifies Hash/Array message content (tool results) to JSON text


777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
# File 'lib/pwn/ai/agent/loop.rb', line 777

public_class_method def self.ollama_wire_messages(opts = {})
  messages = opts[:messages]
  Array(messages).filter_map do |m|
    next unless m.is_a?(Hash)

    role = (m[:role] || m['role']).to_s
    out = { role: role }

    tcs = m[:tool_calls] || m['tool_calls']
    wired_tcs = nil
    if tcs
      wired_tcs = Array(tcs).filter_map { |tc| ollama_wire_tool_call(tool_call: tc) }
      out[:tool_calls] = wired_tcs unless wired_tcs.empty?
    end

    if m.key?(:content) || m.key?('content')
      content = m.key?(:content) ? m[:content] : m['content']
      out[:content] = case content
                      when nil
                        # Open WebUI: null content without tool_calls 400s;
                        # with tool_calls prefer "" over null.
                        wired_tcs && !wired_tcs.empty? ? '' : nil
                      when String then content
                      when Hash, Array then JSON.generate(content)
                      else content.to_s
                      end
    elsif wired_tcs && !wired_tcs.empty?
      out[:content] = ''
    end

    name = m[:name] || m['name']
    out[:name] = name.to_s if name && !name.to_s.empty?

    tcid = m[:tool_call_id] || m['tool_call_id']
    out[:tool_call_id] = tcid.to_s if tcid && !tcid.to_s.empty?

    out
  end
end

.openai_wire_messages(opts = {}) ⇒ Object

Supported Method Parameters

wire = PWN::AI::Agent::Loop.openai_wire_messages( messages: 'required - in-memory OpenAI-ish messages (may have Hash args / internal keys)' )

Returns a deep-copied array safe for OpenAI / xAI chat.completions:

  • drops _native_content / _text_tool_coerced / thinking private keys
  • stringifies function.arguments maps
  • coerces Hash/non-string content to JSON/string (nil kept for assistant tool turns)


861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
# File 'lib/pwn/ai/agent/loop.rb', line 861

public_class_method def self.openai_wire_messages(opts = {})
  messages = opts[:messages]
  Array(messages).filter_map do |m|
    next unless m.is_a?(Hash)

    role = (m[:role] || m['role']).to_s
    out = { role: role }

    if m.key?(:content) || m.key?('content')
      content = m.key?(:content) ? m[:content] : m['content']
      out[:content] = case content
                      when nil then nil
                      when String then content
                      when Hash, Array then JSON.generate(content)
                      else content.to_s
                      end
    end

    name = m[:name] || m['name']
    out[:name] = name.to_s if name && !name.to_s.empty?

    tcid = m[:tool_call_id] || m['tool_call_id']
    out[:tool_call_id] = tcid.to_s if tcid && !tcid.to_s.empty?

    tcs = m[:tool_calls] || m['tool_calls']
    if tcs
      wired = Array(tcs).filter_map { |tc| openai_wire_tool_call(tool_call: tc) }
      out[:tool_calls] = wired unless wired.empty?
    end

    out
  end
end

.recon_authorized?(opts = {}) ⇒ Boolean

Returns:

  • (Boolean)


1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
# File 'lib/pwn/ai/agent/loop.rb', line 1377

public_class_method def self.recon_authorized?(opts = {})
  req = opts[:request].to_s
  return true if req.match?(AUTH_SCOPE_RX)

  # Explicit engage flag from operator / REPL
  v = (PWN::Env.dig(:ai, :agent, :recon_authorized) if defined?(PWN::Env))
  return true if v == true || v.to_s =~ /\A(1|true|yes|on)\z/i

  false
rescue StandardError
  false
end

.request_intent(opts = {}) ⇒ Object



1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
# File 'lib/pwn/ai/agent/loop.rb', line 1287

public_class_method def self.request_intent(opts = {})
  req = opts[:request].to_s
  return :empty if req.strip.empty?

  # Pure greeting / weather smalltalk before how-to/recon/act.
  # Deterministic short-circuit — never freeform model weather echo.
  return :greeting if req.match?(GREETING_RX)

  # Pure prior-turn recall before how-to/recon (short, decisive).
  return :recall if req.match?(RECALL_RX)

  # Vague memory cues that are still "about the prior turn" and not
  # general work ("remember what we decided about nmap and implement it"
  # stays :act because it pairs memory with a doing verb outside the cue).
  if req.match?(VAGUE_MEMORY_RX) && !req.match?(HOWTO_RX) && !req.match?(LIVE_RECON_RX)
    doing = req.match?(
      /\b(implement|fix|patch|refactor|run|execute|scan|write|edit|
          change|deploy|install|build|compile|commit|push)\b/ix
    )
    return :recall unless doing
  end

  # Live-action recon takes precedence over bare "how to" when both appear
  # only if the user clearly asks the agent to do the sweep here.
  live = req.match?(LIVE_RECON_RX) && req.match?(
    /\b(can\s+you|could\s+you|please|go\s+ahead|now|on\s+this\s+host|
        this\s+subnet|this\s+network|find\s+(?:for\s+me|me)|discover)\b/ix
  )
  return :recon_act if live || (req.match?(LIVE_RECON_RX) && !req.match?(HOWTO_RX))
  return :howto if req.match?(HOWTO_RX)
  # Interrogative documentation without "how to"
  if req.match?(/\b(what\s+(?:flags?|options?|switches?)|usage|syntax)\b/i) &&
     !req.match?(/\b(run|execute|scan|find|discover)\b/i)
    return :howto
  end

  :act
rescue StandardError
  :act
end

.request_kind(opts = {}) ⇒ Object

Top-level request kind for task planning (statement | question | autonomous_goal). Single source of truth: TaskSummarizer.request_kind (LLM + heuristics). Mirrors intent/heuristics only when TaskSummarizer is unavailable.

Supported Method Parameters

kind = PWN::AI::Agent::Loop.request_kind( request: 'required - user text', kind: 'optional - precomputed', llm_kind: 'optional - injected LLM label', heuristic_only: 'optional - skip LLM' )



1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
# File 'lib/pwn/ai/agent/loop.rb', line 1339

public_class_method def self.request_kind(opts = {})
  req = opts[:request].to_s
  if defined?(TaskSummarizer) && TaskSummarizer.respond_to?(:request_kind)
    return TaskSummarizer.request_kind(
      request: req,
      kind: opts[:kind],
      llm_kind: opts[:llm_kind],
      heuristic_only: opts[:heuristic_only]
    )
  end

  case request_intent(request: req)
  when :greeting, :empty
    :statement
  when :howto, :recall
    :question
  when :recon_act
    :autonomous_goal
  else
    # :act — distinguish bare questions from work the agent must do.
    # Host-local facts need tools → autonomous_goal.
    if defined?(TaskSummarizer) && TaskSummarizer.const_defined?(:NEEDS_LOCAL_EVIDENCE_RX)
      return :autonomous_goal if req.match?(TaskSummarizer::NEEDS_LOCAL_EVIDENCE_RX)
    elsif req.match?(/\b(?:hostname|whoami|\bcwd\b|\bpwd\b|my\s+ip)\b/i)
      return :autonomous_goal
    end
    return :question if req.match?(/\?\s*\z/) && !req.match?(
      /\b(please|implement|fix|patch|refactor|run|scan|find|write|change)\b/i
    )
    return :question if req.match?(/\A\s*(?:what|why|when|where|who|which|how)\b/i) &&
                        !req.match?(/\b(please|implement|fix|patch|run|scan)\b/i)

    :autonomous_goal
  end
rescue StandardError
  :autonomous_goal
end

.run(opts = {}) ⇒ Object

Supported Method Parameters

final = PWN::AI::Agent::Loop.run( request: 'required - what the human typed', session_id: 'optional - PWN::Sessions id (transcript is appended to it)', enabled_toolsets: 'optional - subset of Registry.toolsets, or nil for all', on_tool: 'optional - ->(name, args, result) callback for live UI', system_role_content: 'optional - override default system prompt (built from session_id if not provided)' )



1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
# File 'lib/pwn/ai/agent/loop.rb', line 1864

public_class_method def self.run(opts = {})
  request = opts[:request].to_s
  session_id = opts[:session_id]
  on_tool = opts[:on_tool]
  # Live coalesced "what am I doing" lines for the TUI (not a model tool).
  ts_state = (TaskSummarizer.fresh(request: request) if defined?(TaskSummarizer) && TaskSummarizer.enabled? && Thread.current[:pwn_reflect_depth].to_i.zero?)
  engine = active_engine
  local  = local_engine?(engine: engine)
  system_role_content = opts[:system_role_content] ||= PWN::AI::Agent::PromptBuilder.build(session_id: session_id, request: request)

  Registry.discover
  maybe_refresh_extro_snapshot!
  opts[:enabled_toolsets] = default_interactive_toolsets(request: request) unless opts.key?(:enabled_toolsets)
  expose_current_session(session_id: session_id)
  Mistakes.check_user_correction(request: request, session_id: session_id) if defined?(Mistakes)

  intent = request_intent(request: request)
  kind = request_kind(request: request)
  Thread.current[:pwn_request_intent] = intent
  Thread.current[:pwn_request_kind] = kind
  Thread.current[:pwn_recon_authorized] = recon_authorized?(request: request)
  Thread.current[:pwn_extinguished] = {}
  # Greeting / light smalltalk: deterministic ack — no weather echo, no tools.
  if intent == :greeting && opts[:force_tools] != true
    return answer_greeting(
      request: request,
      session_id: session_id
    )
  end
  # How-to: never enter plan_first / task recon / tool thrash (ollama/openwebui).
  if intent == :howto && opts[:force_tools] != true
    return answer_howto(
      request: request,
      session_id: session_id,
      system_role_content: system_role_content
    )
  end
  # Pure prior-turn / vague memory recall: one cheap path, no plan_first.
  if intent == :recall && opts[:force_tools] != true
    return answer_recall(
      request: request,
      session_id: session_id,
      system_role_content: system_role_content
    )
  end
  # General statements: acknowledge briefly — no multi-step task plan.
  # Kind is source of truth (LLM+heuristic). Never short-circuit goals.
  if kind.to_sym == :statement && intent != :recon_act && opts[:force_tools] != true
    return answer_statement(
      request: request,
      session_id: session_id
    )
  end
  # Pure questions that are not how-to/recall: concise answer, no multi-step plan.
  # Host-evidence interrogatives classify as autonomous_goal above so they
  # keep tools (e.g. "what is my hostname?"). force_tools bypasses for tests.
  if kind.to_sym == :question && !%i[recon_act].include?(intent) && opts[:force_tools] != true
    return answer_question(
      request: request,
      session_id: session_id,
      system_role_content: system_role_content
    )
  end

  # R5 — open the live MDP episode BEFORE the first Registry.rank so
  # Q(s,a) can advise this turn. Planning still owns the task list.
  if defined?(PWN::AI::Agent::Policy) && Policy.respond_to?(:begin_episode)
    Policy.begin_episode(
      session_id: session_id,
      request: request,
      kind: kind,
      intent: intent,
      engine: engine,
      ts_state: ts_state
    )
  end

  # Initial tool pool from the user request (bootstrap only). After
  # TaskSummarizer.emit_plan! we re-rank using English tangible tasks
  # so generated tasks — not the bare request — drive which tools
  # the model may call.
  tools    = Registry.definitions(enabled: opts[:enabled_toolsets], relevance: request)
  messages = [{ role: 'system', content: system_role_content }]
  messages.concat(Learning.exemplars_for(request: request)) if local && defined?(Learning) && Learning.respond_to?(:exemplars_for)
  messages << { role: 'user', content: request }
  append_session(session_id: session_id, role: 'user', content: request)

  # Tangible-task breakdown ONLY for autonomous goals.
  # General statements and questions stay without multi-step plans.
  needs_breakdown =
    if defined?(TaskSummarizer) && TaskSummarizer.respond_to?(:needs_task_breakdown?)
      TaskSummarizer.needs_task_breakdown?(kind: kind, request: request)
    else
      kind.to_sym == :autonomous_goal
    end
  ts_state[:request_kind] = kind if ts_state.is_a?(Hash)
  if needs_breakdown
    task_summary_plan!(state: ts_state, request: request, on_tool: on_tool)
  elsif ts_state.is_a?(Hash) && defined?(TaskSummarizer)
    # Record kind on state; optional one-line kind banner (no task list).
    ts_state[:plan] = []
    ts_state[:request_kind] = kind
    if TaskSummarizer.respond_to?(:format_plan)
      banner = TaskSummarizer.format_plan(tasks: [], request: request, request_kind: kind)
      if banner && !banner.to_s.empty?
        ts_state[:plan_text] = banner
        ts_state[:plan_emitted] = true
        emit_task_summary(line: banner, on_tool: on_tool)
      end
    end
  end
  # Re-bind tools from English plan so task list is the sole driver of
  # tool exposure/ranking (Registry keyword router + CORE).
  if ts_state.is_a?(Hash) && defined?(TaskSummarizer) && TaskSummarizer.respond_to?(:relevance_query)
    rq = TaskSummarizer.relevance_query(state: ts_state, request: request)
    tools = Registry.definitions(enabled: opts[:enabled_toolsets], relevance: rq) unless rq.to_s.strip.empty?
  end
  # English-task-as-primary: inject tangible tasks only for autonomous goals.
  inject_task_focus!(messages: messages, state: ts_state, force: true) if needs_breakdown

  predicted = nil
  Thread.current[:pwn_plan_predicted] = nil
  cal_state = calibration_state
  force_plan = cal_state[:force_plan]
  # Skip plan_first for non-goals (statements/questions) and cheap intents.
  skip_plan = %i[howto recall greeting].include?(intent) ||
              %i[statement question].include?(kind.to_sym) ||
              !needs_breakdown
  if !skip_plan && (force_plan || agent_flag(key: :plan_first, default: local) || budget_exhaustion_hot?) && !Array(tools).empty?
    predicted = plan_first(messages: messages, request: request, ts_state: ts_state)
    # P22 — prefer explicit return; fall back to thread stash
    predicted = Thread.current[:pwn_plan_predicted] if predicted.nil?
    # unify_plan! may have rewritten English tasks — force refresh focus.
    # Re-rank tools from (possibly unified) English plan; never from
    # PLAN: tool-call scaffold jargon (unify_plan! refuses that).
    if ts_state.is_a?(Hash) && defined?(TaskSummarizer) && TaskSummarizer.respond_to?(:relevance_query)
      rq = TaskSummarizer.relevance_query(state: ts_state, request: request)
      tools = Registry.definitions(enabled: opts[:enabled_toolsets], relevance: rq) unless rq.to_s.strip.empty?
    end
    inject_task_focus!(messages: messages, state: ts_state, force: true)
  end
  if budget_exhaustion_hot?
    hot_hint = if local_engine?
                 '[pwn-ai/p17] Budget-exhaustion is the top open failure on this host. ' \
                   'Prefer the SHORTEST plan that finishes the ask (≤3 tool calls). ' \
                   'Emit a final answer as soon as you have evidence — do not explore.'
               else
                 '[pwn-ai/p17] Budget-exhaustion is the top open failure on this host. ' \
                   'Prefer the shortest plan that FULLY finishes the ask — no polite ' \
                   'handoffs, no exploration side-quests. Emit a final answer as soon ' \
                   'as you have evidence; keep going with tools until the goal is done ' \
                   'or truly blocked.'
               end
    messages << { role: 'user', content: hot_hint }
  end
  if force_plan && cal_state[:cal]
    messages << {
      role: 'user',
      content: "[pwn-ai/w3] engine=#{active_engine} is overconfident " \
               "(brier=#{cal_state[:cal][:brier]}, overconf=#{cal_state[:cal][:overconfidence]}). " \
               'Prefer high-judge exemplars, verify claims, and avoid speculative tool calls.'
    }
  end

  turn_fails = Hash.new(0)
  escalated  = false
  maybe_park_budget_scars!
  maybe_extinguish_parked!

  max_iters.times do |i|
    # 3.1 — compact history on local so tool dumps don't fill num_ctx
    compact_history!(messages: messages) if local
    # English-task-as-primary: when plan_idx advanced, tell the model
    # which plain-English task is active before the next tool batch.
    inject_task_focus!(messages: messages, state: ts_state)

    # P17 — on the final iteration, strip tools and demand a plain-text
    # answer. Without this the model happily emits one more tool_calls
    # batch, burns the last slot, and lands on budget_exhausted with
    # nothing the user (or ORM) can use.
    # P17 deepen — when budget_hot, force text-only on the LAST TWO
    # iters so a final tool_calls batch cannot burn the terminal slot.
    # P17 deepen³ — under hot, force text-only on last THREE of the
    # 8-iter cap so a late tool binge cannot burn every salvage slot.
    # P17 structural: default hot text-only tail stays 3 (do NOT deepen to 4/6).
    # Plan-faithful headroom — short plan executing cleanly → delay strip to
    # last 1–2 so multi-step goals are not predestined to exhaust under cap 8.
    hot = budget_exhaustion_hot?
    plan_steps = begin
      predicted_plan = predicted || Thread.current[:pwn_plan_predicted]
      if predicted_plan.is_a?(Hash)
        Array(predicted_plan[:steps] || predicted_plan[:tools] || predicted_plan[:plan]).size
      elsif predicted_plan.is_a?(Array)
        predicted_plan.size
      else
        predicted_plan.to_s.scan(/\b(?:shell|pwn_eval|memory_|mistakes_|skill_|extro_|learning_|sessions_)\w*/).size
      end
    rescue StandardError
      0
    end
    # Plan-faithful: delay the text-only strip when a plan is executing
    # cleanly. Remote hot allows longer plans (runway 25); local hot
    # still favors short plans under the 8-iter cap.
    plan_step_limit = local_engine? ? 3 : 12
    plan_faithful = hot && plan_steps.positive? && plan_steps <= plan_step_limit &&
                    turn_fails['empty_final'].to_i.zero? &&
                    turn_fails.values.sum < 2
    text_only_iters = if hot
                        if plan_faithful
                          1
                        else
                          (local_engine? ? 3 : 2)
                        end
                      else
                        1
                      end
    last_iter = (i >= max_iters - text_only_iters)
    if last_iter
      tag = i >= max_iters - 1 ? 'FINAL ITERATION' : 'PENULTIMATE — wrap up'
      messages << {
        role: 'user',
        content: "[pwn-ai/p17] #{tag} — do NOT call any more tools. " \
                 'Write the best complete answer you can from evidence already in this ' \
                 'transcript. If the goal is unfinished, report exactly what is done, ' \
                 'what is blocked, and the concrete remaining work — do NOT ask the ' \
                 'user to confirm the next step.'
      }
    end

    msg = call_engine(messages: messages, tools: last_iter ? nil : tools)
    if msg.nil?
      task_summary_flush!(state: ts_state, on_tool: on_tool)
      return '[pwn-ai] engine returned no message'
    end

    calls = Array(msg[:tool_calls])
    text  = msg[:content].to_s

    # Belt-and-suspenders: plain-text shell(...) / tool forms from local
    # models under weak TEMPLATE {{ .Prompt }} become real tool_calls.
    if calls.empty? && !text.strip.empty? && !last_iter &&
       defined?(Dispatch) && Dispatch.respond_to?(:tool_calls_from_text)
      coerced = Dispatch.tool_calls_from_text(text: text)
      if coerced.any?
        wired = coerced.map { |tc| openai_wire_tool_call(tool_call: tc) }
        msg = msg.merge(tool_calls: wired, content: nil, _text_tool_coerced: true)
        calls = wired
        text = ''
        warn "[pwn-ai/loop] coerced #{wired.length} text tool call(s) on iter=#{i}" if local
      end
    end

    # Empty-final guard (local/thinking models): Ollama sometimes
    # returns done_reason=stop with eval_count<=1, empty content, no
    # tool_calls — historically surface as a blank TUI reply. Do NOT
    # commit that as the answer; drop the empty assistant turn,
    # inject a one-shot nudge, and keep iterating.
    if calls.empty? && text.strip.empty?
      warn "[pwn-ai/loop] empty final from #{engine} on iter=#{i}; nudging" if local
      messages << {
        role: 'user',
        content: 'Your previous reply was empty (no tool_calls and no content). ' \
                 'Either call a tool now, or write the final answer for the user as plain text. ' \
                 'Do not reply with an empty message.'
      }
      turn_fails['empty_final'] += 1
      next
    end

    messages << msg

    if calls.empty?
      # P28 — refuse polite mid-goal handoffs so multi-step tasks stay autonomous.
      if incomplete_final?(text: text, last_iter: last_iter) && turn_fails['incomplete_final'].to_i < 4
        turn_fails['incomplete_final'] += 1
        warn "[pwn-ai/loop] incomplete final on iter=#{i}; continuing autonomously"
        messages << {
          role: 'user',
          content: '[pwn-ai/p28] That reply was incomplete (handoff or narrated next step). ' \
                   'Do NOT monologue about what you will try. Do NOT ask the user to ' \
                   'confirm. Emit NATIVE tool_calls NOW (e.g. shell with a concrete ' \
                   'command). Never print shell(...) as plain text. Only emit a final ' \
                   'answer when the request is complete or truly blocked with evidence.'
        }
        next
      end
      append_session(session_id: session_id, role: 'assistant', content: text)
      Learning.auto_introspect(session_id: session_id, request: request, final: text, predicted: predicted, plan: ts_state && ts_state[:plan], ts_state: ts_state) if defined?(Learning) && should_auto_introspect?(local: local, turn_fails: turn_fails, iter: i)
      maybe_finish_policy(session_id: session_id, proxy_ok: true, ts_state: ts_state)
      task_summary_flush!(state: ts_state, on_tool: on_tool)
      return text
    end

    # One executive task brief for the whole collection, then the
    # individual tool lines. pwn-ai → task is one-to-many with tools.
    task_summary_about_to!(
      state: ts_state,
      tools: calls.map do |tool_call|
        {
          name: tool_call.dig(:function, :name).to_s,
          args: tool_call.dig(:function, :arguments)
        }
      end,
      request: request,
      on_tool: on_tool
    )

    calls.each do |tc|
      name    = tc.dig(:function, :name).to_s
      args    = tc.dig(:function, :arguments)
      entry   = Registry.lookup(name: name)
      started = Time.now
      if Thread.current[:pwn_extinguished].is_a?(Hash) && Thread.current[:pwn_extinguished][name]
        raw = JSON.generate(
          success: false,
          error: "extinguished_repeat: #{name} already failed this signature this turn — change args or tool",
          result: { stdout: '', stderr: "extinguished_repeat: #{name}", exit: 2 }
        )
      else
        raw = Dispatch.call(tool_call: tc)
      end
      tele    = record_metrics(name: name, started: started, raw: raw, args: args, session_id: session_id, engine: engine, ts_state: ts_state)
      result  = Result.condition(content: raw, entry: entry)

      unless tele[:ok]
        fkey = Digest::SHA256.hexdigest("#{name}|#{args}")[0, 16]
        turn_fails[fkey] += 1
        persist = tele.dig(:mistake, :count).to_i
        count   = [turn_fails[fkey], persist].max
        hint    = defined?(Mistakes) ? Mistakes.correction_hint(tool: name, error: tele[:err] || raw[0, 300]) : ''
        # S2 — counterfactual A/B: at the repeat threshold, fork an
        # alt-persona branch, judge both, inject the winner. Real
        # advantage estimation; (loser, winner) → DPO preference.
        thresh = defined?(Mistakes) ? Mistakes::REPEAT_THRESHOLD : 3
        # P17 — never fork counterfactual when budget fingerprints dominate:
        # CF is another mini agent loop and is the #1 amplifier of
        # iteration-budget exhaustion on this host.
        if count >= thresh && !escalated && defined?(Curriculum) && !budget_exhaustion_hot?
          cf = (turn_fails["cf:#{fkey}"] += 1) == 1 ? Curriculum.counterfactual(request: request, name: name, args: args, error: tele[:err] || raw[0, 200], hint: hint) : nil
          hint = "#{hint}\n[pwn-ai/counterfactual] branch #{cf[:branch]} (score=#{cf[:score].round(2)}): #{cf[:content]}" if cf
        end
        result = guard_repeated_failure(name: name, count: count, hint: hint, result: result, mistake: tele[:mistake], args: args, shape: tele.dig(:mistake, :shape))
      end

      on_tool&.call(name, args, result)
      task_summary_record!(state: ts_state, name: name, args: args, result: result, on_tool: on_tool)

      messages << {
        role: 'tool',
        tool_call_id: tc[:id] || tc['id'] || "call_#{i}",
        name: name,
        content: result
      }
      append_session(
        session_id: session_id,
        role: 'tool',
        content: "#{name}#{result[0, 1_024]}"
      )
    end

    # P17 — evidence-enough early final (finish-under-N). When tools already
    # answered the ask, inject a synthesis nudge once and let the next
    # non-incomplete text final win — do not burn remaining iters to exhaust.
    if !last_iter && evidence_enough_to_finalize?(
      messages: messages,
      turn_fails: turn_fails,
      i: i,
      max_iters: max_iters,
      request: request,
      plan_steps: plan_steps,
      ts_state: ts_state
    ) && turn_fails['evidence_final'].to_i < 1
      turn_fails['evidence_final'] += 1
      messages << {
        role: 'user',
        content: '[pwn-ai/p17] Evidence from the last tool results is enough to answer. ' \
                 'Do NOT call more tools. Write the complete final answer now from that ' \
                 'evidence. If anything remains blocked, state exactly what and stop.'
      }
    end

    # P17 — hard stop: empty-final thrash or cumulative fails past cap.
    # Prefer a short apologetic final over another 10 useless tool dumps
    # that poison ORM/PRM/DPO with terminal failures.
    empty_n = turn_fails['empty_final'].to_i
    fail_n  = turn_fails.values.sum
    if empty_n >= BUDGET_EMPTY_FINAL_STOP || fail_n >= BUDGET_HARD_STOP_FAILS
      msg = if empty_n >= BUDGET_EMPTY_FINAL_STOP
              '[pwn-ai] stopped: repeated empty finals (budget thrash guard)'
            else
              '[pwn-ai] stopped: too many in-turn failures (budget thrash guard)'
            end
      if defined?(Mistakes)
        Mistakes.record(
          tool: 'agent_loop',
          error: "budget thrash guard fired empty=#{empty_n} fails=#{fail_n} iter=#{i}",
          session_id: session_id,
          source: :loop,
          shape: :budget_thrash
        )
      end
      append_session(session_id: session_id, role: 'assistant', content: msg)
      Learning.auto_introspect(session_id: session_id, request: request, final: msg, predicted: predicted, plan: ts_state && ts_state[:plan], ts_state: ts_state) if defined?(Learning) && should_auto_introspect?(local: local, turn_fails: turn_fails, iter: i)
      maybe_finish_policy(session_id: session_id, proxy_ok: false, ts_state: ts_state)
      task_summary_flush!(state: ts_state, on_tool: on_tool)
      return msg
    end

    next unless local && !escalated && turn_fails.values.sum >= ESCALATE_AFTER_FAILS

    hint = escalate(request: request, turn_fails: turn_fails, session_id: session_id)
    if hint
      messages << { role: 'tool', tool_call_id: "escalation_#{i}", name: 'frontier_hint', content: hint }
      append_session(session_id: session_id, role: 'tool', content: "frontier_hint → #{hint[0, 1_024]}")
    end
    escalated = true
  end

  # P17 — exhaust path must still feed Learning so ORM/PRM/HER see the
  # failure (previously we only Mistakes.record'd and returned a bare
  # string — no session row, no judge, no hindsight).
  final_msg = '[pwn-ai] iteration budget exhausted'
  if defined?(Mistakes)
    Mistakes.record(
      tool: 'agent_loop',
      error: 'iteration budget exhausted without a final answer',
      session_id: session_id,
      source: :loop,
      shape: :budget_exhausted
    )
  end
  append_session(session_id: session_id, role: 'assistant', content: final_msg)
  Learning.auto_introspect(session_id: session_id, request: request, final: final_msg, predicted: predicted, plan: ts_state && ts_state[:plan], ts_state: ts_state) if defined?(Learning) && should_auto_introspect?(local: local, turn_fails: turn_fails, iter: max_iters)
  maybe_finish_policy(session_id: session_id, proxy_ok: false, ts_state: ts_state)
  task_summary_flush!(state: ts_state, on_tool: on_tool)
  final_msg
end