pwn 0.5.741 → 0.5.742
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/pwn/ai/agent/registry.rb +20 -1
- data/lib/pwn/ai/grok.rb +5 -3
- data/lib/pwn/ai/ollama.rb +1 -1
- data/lib/pwn/ai/open_web_ui.rb +1 -1
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/registry_spec.rb +27 -0
- data/spec/lib/pwn/ai/grok_spec.rb +105 -0
- data/spec/lib/pwn/ai/ollama_spec.rb +17 -0
- data/spec/lib/pwn/ai/open_web_ui_spec.rb +17 -0
- data/third_party/pwn_rdoc.jsonl +1 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aae26246e4b23381c5f727d27100f6a10d37d955f2c9a875ad63274d3397219b
|
|
4
|
+
data.tar.gz: 1a0abc7df30321bd4618a8db3d356fa72e9b6cc13f6055b5f71bf4fb1b4ec90a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b680d2d2d3eb756642d68a6309a36a5d551100131b58323954bd402c73f554515304ecfb005a42e96ab15a385f16d717495cf527c2bf17c87434567401d0dd0
|
|
7
|
+
data.tar.gz: 7b51570ad19e664e45ca69925619fdbdda6272f985d8627f4c247815b469de19cb11bdfa942433480bbc38792127a8b39e45e56c08c8c73ffe517318ad004396
|
|
@@ -144,12 +144,31 @@ module PWN
|
|
|
144
144
|
|
|
145
145
|
pool = apply_preference({ items: pool }.merge(pref_fwd))
|
|
146
146
|
pool.map do |entry|
|
|
147
|
-
schema = entry.schema
|
|
147
|
+
schema = entry.schema.merge(parameters: explicit_root_types(schema: entry.schema[:parameters]))
|
|
148
148
|
schema = schema.merge(description: "#{schema[:description]} #{mcp[:description]}") if entry.name == 'mcp'
|
|
149
149
|
{ type: 'function', function: schema }
|
|
150
150
|
end
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
+
# Providers inspect union branches independently of their object parent.
|
|
154
|
+
# Inherit only along same-instance combinators, never into properties or
|
|
155
|
+
# array items (which may legitimately be scalar/object unions).
|
|
156
|
+
private_class_method def self.explicit_root_types(opts = {})
|
|
157
|
+
schema = opts[:schema]
|
|
158
|
+
return schema unless schema.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
result = schema.dup
|
|
161
|
+
type = schema[:type] || schema['type'] || opts[:inherited_type]
|
|
162
|
+
result[:type] = type if type && !schema.key?(:type) && !schema.key?('type')
|
|
163
|
+
%i[anyOf oneOf allOf].each do |combiner|
|
|
164
|
+
key = schema.key?(combiner) ? combiner : combiner.to_s
|
|
165
|
+
next unless schema[key].is_a?(Array)
|
|
166
|
+
|
|
167
|
+
result[key] = schema[key].map { |branch| explicit_root_types(schema: branch, inherited_type: type) }
|
|
168
|
+
end
|
|
169
|
+
result
|
|
170
|
+
end
|
|
171
|
+
|
|
153
172
|
# Supported Method Parameters::
|
|
154
173
|
# order = PWN::AI::Agent::Registry.preference_order(
|
|
155
174
|
# order: 'optional - Array of tool names; explicit nil/[] disables',
|
data/lib/pwn/ai/grok.rb
CHANGED
|
@@ -476,7 +476,7 @@ module PWN
|
|
|
476
476
|
extra: '429 retries exhausted', error: e
|
|
477
477
|
)
|
|
478
478
|
end
|
|
479
|
-
|
|
479
|
+
raise
|
|
480
480
|
end
|
|
481
481
|
sleep(PWN::AI::HttpRetry.retry_after_s(response: e.response, retry_count: retry_count) + rand(0.3..5.0))
|
|
482
482
|
retry
|
|
@@ -497,8 +497,10 @@ module PWN
|
|
|
497
497
|
nil
|
|
498
498
|
end
|
|
499
499
|
rescue RestClient::ExceptionWithResponse => e
|
|
500
|
-
|
|
501
|
-
|
|
500
|
+
# Keep the HTTP type/response for callers, and surface provider diagnostics
|
|
501
|
+
# instead of turning failed requests into nil or unparseable JSON text.
|
|
502
|
+
e.message = "Grok HTTP #{e.http_code} (#{http_method.to_s.upcase} #{rest_call}): #{e.response&.body}"
|
|
503
|
+
raise
|
|
502
504
|
rescue StandardError => e
|
|
503
505
|
case e.message
|
|
504
506
|
when '400 Bad Request', '404 Resource Not Found'
|
data/lib/pwn/ai/ollama.rb
CHANGED
data/lib/pwn/ai/open_web_ui.rb
CHANGED
data/lib/pwn/version.rb
CHANGED
|
@@ -47,6 +47,33 @@ describe PWN::AI::Agent::Registry do
|
|
|
47
47
|
expect(names.length).to eq(described_class::CORE_TOOLS.length)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
it 'exports explicitly object-typed root alternatives without changing registered schemas' do
|
|
51
|
+
described_class.discover
|
|
52
|
+
original = Marshal.dump(described_class.lookup(name: 'artifact_read').schema)
|
|
53
|
+
definitions = described_class.definitions(core_only: false, top_k: 0)
|
|
54
|
+
check = lambda do |schema|
|
|
55
|
+
%i[anyOf oneOf allOf].each do |key|
|
|
56
|
+
Array(schema[key]).each do |branch|
|
|
57
|
+
expect(branch[:type]).to eq('object')
|
|
58
|
+
check.call(branch)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
definitions.each { |tool| check.call(tool[:function][:parameters]) }
|
|
63
|
+
tool = definitions.find { |entry| entry[:function][:name] == 'artifact_read' }
|
|
64
|
+
expect(tool[:function][:parameters][:anyOf].map { |branch| branch[:required] }).to eq([%w[handle], %w[path], %w[ref]])
|
|
65
|
+
expect(Marshal.dump(described_class.lookup(name: 'artifact_read').schema)).to eq(original)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'preserves property-level scalar unions and explicit branch types' do
|
|
69
|
+
schema = { 'type' => 'object', 'oneOf' => [{ 'required' => ['value'] }],
|
|
70
|
+
'properties' => { 'value' => { 'anyOf' => [{ 'type' => 'string' }, { 'type' => 'object' }] } } }
|
|
71
|
+
normalized = described_class.send(:explicit_root_types, schema: schema)
|
|
72
|
+
expect(normalized['oneOf'].first[:type]).to eq('object')
|
|
73
|
+
expect(normalized['properties']).to eq(schema['properties'])
|
|
74
|
+
expect(schema['oneOf'].first).to eq('required' => ['value'])
|
|
75
|
+
end
|
|
76
|
+
|
|
50
77
|
it 'advertises the MCP broker for a named backend in the normal core-only prompt path' do
|
|
51
78
|
described_class.discover
|
|
52
79
|
request = 'Use combo.nation to inspect the menu catalog'
|
|
@@ -18,6 +18,111 @@ describe PWN::AI::Grok do
|
|
|
18
18
|
expect(src).to match(/openai_wire_messages/)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
describe '.chat_with_tools HTTP failures' do
|
|
22
|
+
let(:messages) { [{ role: 'user', content: 'hello' }] }
|
|
23
|
+
let(:body) { '{"error":"Invalid schema: tools[0].function.parameters requires properties"}' }
|
|
24
|
+
let(:response) { instance_double(RestClient::Response, code: 400, body: body, to_s: body) }
|
|
25
|
+
let(:error) { RestClient::BadRequest.new(response) }
|
|
26
|
+
|
|
27
|
+
before do
|
|
28
|
+
stub_const('PWN::Env', { ai: { grok: { key: 'test-key', model: 'test-model' } } })
|
|
29
|
+
allow(PWN::Plugins::TransparentBrowser).to receive(:open).with(browser_type: :rest).and_return(browser: RestClient)
|
|
30
|
+
allow(PWN::AI::Agent::ToolGuard).to receive(:protect_http!)
|
|
31
|
+
allow(PWN::AI::Agent::PromptCache).to receive(:enabled?).and_return(false)
|
|
32
|
+
allow(PWN::Plugins::TTYSpinner).to receive(:stop)
|
|
33
|
+
allow(RestClient::Request).to receive(:execute).and_raise(error)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'serializes explicit artifact_read branch types from the real registry' do
|
|
37
|
+
tools = PWN::AI::Agent::Registry.definitions(core_only: true)
|
|
38
|
+
allow(RestClient::Request).to receive(:execute).and_return(
|
|
39
|
+
'{"choices":[{"message":{"role":"assistant","content":"hello"}}]}'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
described_class.chat_with_tools(messages: messages, tools: tools)
|
|
43
|
+
|
|
44
|
+
expect(RestClient::Request).to have_received(:execute) do |request|
|
|
45
|
+
payload = JSON.parse(request[:payload])
|
|
46
|
+
artifact_read = payload.fetch('tools').find { |tool| tool.dig('function', 'name') == 'artifact_read' }
|
|
47
|
+
expect(artifact_read).not_to be_nil
|
|
48
|
+
branches = artifact_read.fetch('function').fetch('parameters').fetch('anyOf')
|
|
49
|
+
expect(branches).not_to be_empty
|
|
50
|
+
expect(branches).to all(include('type' => 'object'))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
context 'when rate limits exhaust the retry budget' do
|
|
55
|
+
let(:body) { '{"error":"rate limit exceeded"}' }
|
|
56
|
+
let(:response) { instance_double(RestClient::Response, code: 429, body: body, to_s: body, headers: { retry_after: '2' }) }
|
|
57
|
+
let(:error) { RestClient::TooManyRequests.new(response) }
|
|
58
|
+
|
|
59
|
+
it 'raises the HTTP error after five attempts and four Retry-After waits' do
|
|
60
|
+
allow(described_class).to receive(:rand).with(0.3..5.0).and_return(0.5)
|
|
61
|
+
allow(described_class).to receive(:sleep)
|
|
62
|
+
allow(PWN::AI::HttpRetry).to receive(:report_event)
|
|
63
|
+
|
|
64
|
+
expect do
|
|
65
|
+
described_class.chat_with_tools(messages: messages)
|
|
66
|
+
end.to raise_error(RestClient::TooManyRequests) { |raised|
|
|
67
|
+
expect(raised.message).to include('HTTP 429', body)
|
|
68
|
+
}
|
|
69
|
+
expect(RestClient::Request).to have_received(:execute).exactly(5).times
|
|
70
|
+
expect(described_class).to have_received(:sleep).with(2.5).exactly(4).times
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
context 'when the request times out' do
|
|
75
|
+
let(:error) { RestClient::Exceptions::ReadTimeout.new }
|
|
76
|
+
|
|
77
|
+
it 'preserves the default five attempts and nil on exhaustion' do
|
|
78
|
+
allow(PWN::AI::HttpRetry).to receive(:report_event)
|
|
79
|
+
expect(described_class.chat_with_tools(messages: messages)).to be_nil
|
|
80
|
+
expect(RestClient::Request).to have_received(:execute).with(hash_including(timeout: 180)).exactly(5).times
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'keeps quiet sidecars single-shot and silent' do
|
|
84
|
+
expect(PWN::AI::HttpRetry).not_to receive(:report_event)
|
|
85
|
+
expect(described_class.chat_with_tools(messages: messages, quiet: true)).to be_nil
|
|
86
|
+
expect(RestClient::Request).to have_received(:execute).once
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'keeps short timeouts single-shot' do
|
|
90
|
+
allow(PWN::AI::HttpRetry).to receive(:report_event)
|
|
91
|
+
expect(described_class.chat_with_tools(messages: messages, timeout: 10)).to be_nil
|
|
92
|
+
expect(RestClient::Request).to have_received(:execute).with(hash_including(timeout: 10)).once
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'returns the successful response after a transient timeout' do
|
|
96
|
+
calls = 0
|
|
97
|
+
allow(RestClient::Request).to receive(:execute) do
|
|
98
|
+
calls += 1
|
|
99
|
+
raise error if calls == 1
|
|
100
|
+
|
|
101
|
+
'{"choices":[{"message":{"role":"assistant","content":"hello"}}]}'
|
|
102
|
+
end
|
|
103
|
+
allow(PWN::AI::HttpRetry).to receive(:report_event)
|
|
104
|
+
result = described_class.chat_with_tools(messages: messages)
|
|
105
|
+
expect(result.dig(:assistant_message, :content)).to eq('hello')
|
|
106
|
+
expect(RestClient::Request).to have_received(:execute).twice
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
[false, true].each do |quiet|
|
|
111
|
+
it "propagates actionable schema errors without printing (quiet=#{quiet})" do
|
|
112
|
+
expect do
|
|
113
|
+
expect do
|
|
114
|
+
described_class.chat_with_tools(messages: messages, quiet: quiet)
|
|
115
|
+
end.to raise_error(RestClient::BadRequest) { |raised|
|
|
116
|
+
expect(raised.response).to equal(response)
|
|
117
|
+
expect(raised.message).to include('Grok', 'HTTP 400', 'chat/completions', body)
|
|
118
|
+
}
|
|
119
|
+
end.not_to output.to_stdout
|
|
120
|
+
expect(RestClient::Request).to have_received(:execute).once
|
|
121
|
+
expect(PWN::Plugins::TTYSpinner).to have_received(:stop)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
21
126
|
it 'does not expose get_plan_usage' do
|
|
22
127
|
expect(described_class).not_to respond_to(:get_plan_usage)
|
|
23
128
|
end
|
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
describe PWN::AI::Ollama do
|
|
6
|
+
[false, true].each do |quiet|
|
|
7
|
+
it "raises an actionable exhausted 429 instead of parsing error text (quiet=#{quiet})" do
|
|
8
|
+
stub_const('PWN::Env', { ai: { ollama: { model: 'fixture', base_uri: 'http://example.test' } } })
|
|
9
|
+
allow(PWN::Plugins::TransparentBrowser).to receive(:open).and_return(browser: RestClient)
|
|
10
|
+
response = instance_double(RestClient::Response, code: 429, body: 'rate limit exceeded', to_s: 'rate limit exceeded', headers: { retry_after: '2' })
|
|
11
|
+
allow(RestClient::Request).to receive(:execute).and_raise(RestClient::TooManyRequests.new(response))
|
|
12
|
+
allow(described_class).to receive(:sleep)
|
|
13
|
+
allow(described_class).to receive(:rand).and_return(0.5)
|
|
14
|
+
allow(PWN::AI::HttpRetry).to receive(:report_event)
|
|
15
|
+
expect do
|
|
16
|
+
described_class.chat_with_tools(messages: [{ role: 'user', content: 'hello' }], quiet: quiet)
|
|
17
|
+
end.to raise_error(RuntimeError, /Ollama.*429.*rate limit exceeded/)
|
|
18
|
+
expect(RestClient::Request).to have_received(:execute).exactly(5).times
|
|
19
|
+
expect(described_class).to have_received(:sleep).with(2.5).exactly(4).times
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
6
23
|
it 'should display information for authors' do
|
|
7
24
|
authors_response = PWN::AI::Ollama
|
|
8
25
|
expect(authors_response).to respond_to :authors
|
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
describe PWN::AI::OpenWebUI do
|
|
6
|
+
[false, true].each do |quiet|
|
|
7
|
+
it "raises an actionable exhausted 429 instead of parsing error text (quiet=#{quiet})" do
|
|
8
|
+
stub_const('PWN::Env', { ai: { openwebui: { model: 'fixture', base_uri: 'http://example.test', key: 'fixture-key' } } })
|
|
9
|
+
allow(PWN::Plugins::TransparentBrowser).to receive(:open).and_return(browser: RestClient)
|
|
10
|
+
response = instance_double(RestClient::Response, code: 429, body: 'rate limit exceeded', to_s: 'rate limit exceeded', headers: { retry_after: '2' })
|
|
11
|
+
allow(RestClient::Request).to receive(:execute).and_raise(RestClient::TooManyRequests.new(response))
|
|
12
|
+
allow(described_class).to receive(:sleep)
|
|
13
|
+
allow(described_class).to receive(:rand).and_return(0.5)
|
|
14
|
+
allow(PWN::AI::HttpRetry).to receive(:report_event)
|
|
15
|
+
expect do
|
|
16
|
+
described_class.chat_with_tools(messages: [{ role: 'user', content: 'hello' }], quiet: quiet)
|
|
17
|
+
end.to raise_error(RuntimeError, /Open WebUI.*429.*rate limit exceeded/)
|
|
18
|
+
expect(RestClient::Request).to have_received(:execute).exactly(5).times
|
|
19
|
+
expect(described_class).to have_received(:sleep).with(2.5).exactly(4).times
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
6
23
|
it 'should display information for authors' do
|
|
7
24
|
authors_response = PWN::AI::OpenWebUI
|
|
8
25
|
expect(authors_response).to respond_to :authors
|
data/third_party/pwn_rdoc.jsonl
CHANGED
|
@@ -685,6 +685,7 @@
|
|
|
685
685
|
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.discover Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.discover`: Supported Method Parameters\n\nnames = PWN::AI::Agent::Registry.discover(\n\nforce: 'optional - re-require tool files even if already discovered (default false)'\n\n)\n"}]}
|
|
686
686
|
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.domain_pin_names Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.domain_pin_names`: "}]}
|
|
687
687
|
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.eager_load! Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.eager_load!`: "}]}
|
|
688
|
+
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.explicit_root_types Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.explicit_root_types`: "}]}
|
|
688
689
|
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.help Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.help`: "}]}
|
|
689
690
|
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.lookup Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.lookup`: Supported Method Parameters\n\nentry = PWN::AI::Agent::Registry.lookup(\n\nname: 'required - registered tool name'\n\n)\n"}]}
|
|
690
691
|
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.mcp_routing Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.mcp_routing`: "}]}
|