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

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

Overview

The agent conversation loop:

build system prompt 

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

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

COMPLETION

The original request is the completion signal. TaskSummarizer and Policy are advisory (compass / rank). Loop keeps calling CORE_TOOLS until that request is done or a tool returned failure evidence, then stops.

LOCAL-MODEL SCAFFOLDING

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

* threads request 

Defined Under Namespace

Classes: Steering

Constant Summary collapse

DEFAULT_MAX_ITERS =
777
ESCALATE_AFTER_FAILS =
4
BOUNCE_FAIL_KEYS =
%w[
  unsatisfied incomplete_final empty_final evidence_final
].freeze
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
ACT_REQUEST_RX =
/
  \b(write|create|implement|fix|patch|replace|refactor|overwrite|
     add (?:a |the )?|update|install|delete|remove|rename|
     regenerate|rebuild|document)
  \b
/ix
FORCED_WRAP_RX =
/
  forced\s+to\s+a\s+final|were\s+not\s+written|not\s+written\s+to\s+disk|
  resume\s+from\s+the\s+table|remaining\s+block|
  were\s+not\s+applied|not\s+applied\s+in\s+this\s+turn|
  (?:do\s+that\s+)?next\s+time
/ix
LOOKUP_REQUEST_RX =
/
  \b(what\s+is\s+my|hostname|uname|cwd|whoami|status|version|how\s+many)\b
/ix
SKILLS_CATALOG_RX =
/
  \bskills?\b.{0,40}\b(available|installed|loaded|catalog|list)\b |
  \b(what|which|list)\b.{0,40}\bskills?\b
/ix
HOST_PATH_RX =

Real filesystem paths only — not https://host.tld (that was matching //host.tld).

%r{(?:(?<![.:/])/(?!/)|\./)[\w./-]+\.\w+}
BROWSER_REQUEST_RX =
/
  TransparentBrowser|browser_obj|\bdevtools\b|
  \b(navigate|dump_links|headless_?chrome|watir)\b
/ix
HOUR_WORDS =
{
  'one' => 1, 'two' => 2, 'three' => 3, 'four' => 4, 'five' => 5,
  'six' => 6, 'seven' => 7, 'eight' => 8, 'nine' => 9, 'ten' => 10,
  'eleven' => 11, 'twelve' => 12, 'thirteen' => 13, 'fourteen' => 14,
  'fifteen' => 15, 'sixteen' => 16, 'seventeen' => 17, 'eighteen' => 18,
  'nineteen' => 19, 'twenty' => 20, 'twenty-four' => 24
}.freeze
EMPTY_CONTRACT =
{
  paths: [],
  min_seconds: 0,
  skills: [],
  proofs: [],
  hosts: [],
  techniques: [],
  issue_work: false
}.freeze
AUTH_REFUSE_RX =

A text-only policy/authorization refusal is never "truly blocked". pwn-ai does not decide authorization; operator request is the mandate.

/
  \b(
    i\s+(?:can(?:['’]?t|not)|won['’]?t|will\s+not)\s+(?:help|run|execute|drive|call|probe|do|emit|perform)|
    request\s+refused|
    assistant\s+boundary|
    disallowed\s+here|
    will\s+not\s+(?:run|call|use|emit)\s+(?:core_tools|tools|tool\s+calls)|
    refuse(?:d|s)?\s+(?:live|the\s+request)|
    refusal\s+stands|
    not\s+something\s+i\s+will\s+run|
    (?:terminal|permanent)\s+(?:refusal|block)|
    path\s+closed(?:\s+permanently)?|
    truly\s+blocked
  )\b
/ix
PAYLOAD_SHA256 =
Digest::SHA256
HISTORY_SPILL_DIR =
File.join(Dir.tmpdir, 'pwn-ai-hist')
KEEP_FULL_TOOL_TAILS =
2
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 (same tool loop as :act; no auth gate). :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+(?:(?:your|my|the)\s+)?(?:memory|context)|
    in\s+your\s+memory|
    (?:your|my)\s+memory\s+(?:of|about)|
    earlier\s+in\s+(?:this\s+)?(?:session|chat|conversation|turn)|
    previously\s+in\s+(?:this\s+)?(?:session|chat|conversation)|
    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
LAST_SESSION_RX =
/\b(?:in|from|of)\s+(?:the\s+)?(?:last|previous|prior)\s+session\b|\blast\s+session\b/i
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

Class Method Summary collapse

Class Method Details

.authors ⇒ Object

Author(s)

0day Inc. [email protected]



3684
3685
3686
# File 'lib/pwn/ai/agent/loop.rb', line 3684

public_class_method def self.authors
  "AUTHOR(S):\n  0day Inc. <[email protected]>\n"
end

.budget_status(opts = {}) ⇒ Object



3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
# File 'lib/pwn/ai/agent/loop.rb', line 3668

public_class_method def self.budget_status(opts = {})
  t0 = opts[:t0] || Thread.current[:pwn_loop_t0] || Time.now
  elapsed = Time.now - t0
  remain = (opts[:remaining_s] || Thread.current[:pwn_loop_budget_s] || 10_800).to_f - elapsed
  {
    elapsed_s: elapsed.round,
    remaining_tool_budget_s: [remain, 0].max.round,
    mutations_used: Thread.current[:pwn_loop_mutations].to_i,
    mutations_max: 10,
    context_tokens_used: Thread.current[:pwn_loop_tokens].to_i,
    est_max: 128_000
  }
end

.catalog_lookup?(opts = {}) ⇒ Boolean

True only when the ask needs a live host/file/browser effect. World-knowledge questions ("what color is a cherry") do not.

Returns:

  • (Boolean)


677
678
679
680
681
682
683
684
685
686
687
# File 'lib/pwn/ai/agent/loop.rb', line 677

public_class_method def self.catalog_lookup?(opts = {})
  request = opts[:request].to_s.strip
  return false if request.empty?
  return false if request.length > 120
  return false if request.match?(ACT_REQUEST_RX)
  return false if request.match?(HOWTO_RX)

  request.match?(SKILLS_CATALOG_RX)
rescue StandardError
  false
end

.debug_on?(opts = {}) ⇒ Boolean

Returns:

  • (Boolean)


248
249
250
251
252
253
254
255
256
257
# File 'lib/pwn/ai/agent/loop.rb', line 248

public_class_method def self.debug_on?(opts = {})
  return true if opts[:debug]
  return true if defined?(PWN::Plugins::Log) && PWN::Plugins::Log.debug_enabled?

  pry_on = defined?(Pry) && Pry.respond_to?(:config) &&
           Pry.config.respond_to?(:pwn_ai_debug) && Pry.config.pwn_ai_debug
  return true if pry_on

  false
end

.evidence_satisfied?(opts = {}) ⇒ Boolean

Returns:

  • (Boolean)


234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/pwn/ai/agent/loop.rb', line 234

public_class_method def self.evidence_satisfied?(opts = {})
  messages = Array(opts[:messages] || opts[:trace])
  text = opts[:text].to_s
  return false if TurnFinalizer.required_artifacts(request: opts[:request]).any? && completion_unmet(request: opts[:request], messages: messages).any?

  if defined?(TurnFinalizer) && TurnFinalizer.respond_to?(:arbitrate)
    row = TurnFinalizer.arbitrate(request: opts[:request].to_s, messages: messages, paths: [])
    return true if row[:complete] && row[:unmet].empty? && row[:ledger].any? { |_p, v| v[:write] && v[:read] }
  end
  write_or_read_evidenced?(messages: messages) && !text.strip.empty?
rescue StandardError
  false
end

.help ⇒ Object



3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
# File 'lib/pwn/ai/agent/loop.rb', line 3729

public_class_method def self.help
  puts "USAGE:
    # Run debug on and return its result
    #{self}.debug_on?(
      debug: 'optional - debug value consumed by #debug_on?'
    )

    # True when write-then-readback evidence satisfies the original request.
    #{self}.evidence_satisfied?(
      messages: 'optional - Array of role/content hashes',
      trace: 'optional - alias for messages',
      text: 'optional - final answer text',
      request: 'optional - original request'
    )

    # True only when the ask needs a live host/file/browser effect. World-knowledge
    #{self}.catalog_lookup?(
      request: 'required - request value consumed by #catalog_lookup?'
    )

    # Run world knowledge and return its result
    #{self}.world_knowledge?(
      request: 'required - request value consumed by #world_knowledge?'
    )

    # Run needs host work and return its result
    #{self}.needs_host_work?(
      request: 'required - request value consumed by #needs_host_work?'
    )

    # Run ollama wire messages and return its result
    #{self}.ollama_wire_messages(
      messages: 'required - in-memory OpenAI-ish messages (may have String args)'
    )

    # Run openai wire messages and return its result
    #{self}.openai_wire_messages(
      messages: 'required - in-memory OpenAI-ish messages (may have Hash args / internal keys)'
    )

    # Run request intent and return its result
    #{self}.request_intent(
      request: 'optional - request value consumed by #request_intent'
    )

    # Run run and return its result
    #{self}.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)',
      verification_contract: 'optional - host-owned Verification.run checks; execute at final boundary and attribute observed artifacts',
      steering: 'optional - request-owned Loop::Steering control; REPL owns its terminal reader and restarts with revised completion scope',
      trusted_context: 'optional - host-observed capability/prerequisite scope; never copied from model arguments',
      debug: 'optional - debug value consumed by #run',
      from: 'optional - sender account or address to bind as operator',
      account: 'optional - operator account id to bind',
      force_tools: 'optional - force tools value consumed by #run',
      nested: 'optional - true for Swarm/child Loop.run (skip RN footer)',
      engine: 'optional - provider name; defaults to PWN::Env ai.active',
      core_only: 'optional - restrict to CORE_TOOLS plus relevant MCP discovery when true',
      trace: 'optional - enable TracePoint debug for this run',
      debug_tee: 'optional - IO to tee debug logs',
      unattended: 'optional - true starts a durable mission and fails closed without an approved DAG',
      mission_id: 'optional - mission identifier consumed by #run',
      min_seconds: 'optional - named duration that keeps the mission open'
    )

    # Remaining time/token/mutation budget for the current loop.
    #{self}.budget_status(
      t0: 'optional - session start Time (defaults to thread t0)',
      remaining_s: 'optional - override remaining tool budget seconds'
    )

    # Print the AUTHOR(S) string for this module.
    #{self}.authors
  "
  constants.sort
end

.needs_host_work?(opts = {}) ⇒ Boolean

Returns:

  • (Boolean)


706
707
708
709
710
711
712
713
714
715
# File 'lib/pwn/ai/agent/loop.rb', line 706

public_class_method def self.needs_host_work?(opts = {})
  request = opts[:request].to_s
  return false if request.strip.empty?
  return false if world_knowledge?(request: request)
  return false if catalog_lookup?(request: request)

  true
rescue StandardError
  false
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


1794
1795
1796
1797
1798
1799
1800
1801
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
# File 'lib/pwn/ai/agent/loop.rb', line 1794

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)


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

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

.request_intent(opts = {}) ⇒ Object



2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
# File 'lib/pwn/ai/agent/loop.rb', line 2455

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

  if req.match?(LAST_SESSION_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

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



3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
# File 'lib/pwn/ai/agent/loop.rb', line 3038

public_class_method def self.run(opts = {})
  options = opts.merge(request: opts[:request])
  original_system = opts[:system_role_content]
  begin
    run_turn(options)
  rescue Steering::Restart => e
    history = e.messages.dup
    history.pop if history.last&.dig(:role).to_s == 'assistant' && Array(history.last[:tool_calls]).empty?
    repair_steering_history!(messages: history)
    e.instructions.each do |instruction|
      history << { role: 'user', content: instruction }
      append_session(session_id: options[:session_id], role: 'user', content: instruction)
    end
    options = options.merge(request: e.instructions.last, steering_history: history, system_role_content: original_system)
    update_steered_open_goal!(options)
    options.delete(:verification_contract)
    retry
  end
end

.world_knowledge?(opts = {}) ⇒ Boolean

Returns:

  • (Boolean)


689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
# File 'lib/pwn/ai/agent/loop.rb', line 689

public_class_method def self.world_knowledge?(opts = {})
  request = opts[:request].to_s.strip
  return false if request.empty?
  return false if request.length > 120
  return false if catalog_lookup?(request: request)
  return false if request.match?(ACT_REQUEST_RX)
  return false if request.match?(LOOKUP_REQUEST_RX)
  return false if request.match?(HOST_PATH_RX)
  return false if request.match?(BROWSER_REQUEST_RX)
  return false if request.match?(HOWTO_RX)
  return false if request.match?(%r{\b(this\s+(?:host|machine|box|system|subnet|file|repo)|/opt/|implement|scan|hosts?)\b}i)

  request.match?(/\A(?:what|why|who|when|where|which|how)\b/i)
rescue StandardError
  false
end