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
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



2217
2218
2219
# File 'lib/pwn/ai/agent/loop.rb', line 2217

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

.helpObject

Display Usage for this Module



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
# File 'lib/pwn/ai/agent/loop.rb', line 2223

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)
        :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


698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
# File 'lib/pwn/ai/agent/loop.rb', line 698

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)


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
# File 'lib/pwn/ai/agent/loop.rb', line 782

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)


1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
# File 'lib/pwn/ai/agent/loop.rb', line 1298

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



1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
# File 'lib/pwn/ai/agent/loop.rb', line 1208

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' )



1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
# File 'lib/pwn/ai/agent/loop.rb', line 1260

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)' )



1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
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
# File 'lib/pwn/ai/agent/loop.rb', line 1802

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
  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)
  # 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

  # Live recon without authorization: refuse rather than probe the LAN.
  return refuse_unauthorized_recon(request: request, session_id: session_id) if intent == :recon_act && !recon_authorized?(request: request) && opts[:force_tools] != true

  # 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!

  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)
      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
      raw     = Dispatch.call(tool_call: tc)
      tele    = record_metrics(name: name, started: started, raw: raw, args: args, session_id: session_id, engine: engine)
      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)
      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)
      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)
  task_summary_flush!(state: ts_state, on_tool: on_tool)
  final_msg
end