praxo-core 0.1.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +66 -0
- data/api/0.1.0.txt +110 -0
- data/lib/praxo/agent.rb +162 -0
- data/lib/praxo/errors.rb +18 -0
- data/lib/praxo/json.rb +47 -0
- data/lib/praxo/jsonschema.rb +122 -0
- data/lib/praxo/middleware.rb +133 -0
- data/lib/praxo/models.rb +154 -0
- data/lib/praxo/plugin.rb +147 -0
- data/lib/praxo/runtime.rb +224 -0
- data/lib/praxo/toolcall.rb +96 -0
- data/lib/praxo.rb +9 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 58af042d8ca9a28e2090a34faf13dbee0de142463fcb4ad98b16b697baac8554
|
|
4
|
+
data.tar.gz: 87919c6781c1dac6fd8f731289b02cf563e25d4a4fada8ad52321137236e97c6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a26816e4f1f02666c156f3607b4139b1bf3b28a1069e1be4b6853f1854fcbb63dc22217de302a7553713ee9c13c88e9a43a55cc3b689e3743bbc176f09d2c239
|
|
7
|
+
data.tar.gz: 7348cb7bde0fa6f0c47526b15a5b0c5a1f0b003ff06344205d12d770dfbbee8c2da6a421ee14c2b5f4e84173728e2a5c0857d7ff7d9ec3d2300709e9d42268b4
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AxisVela
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# PRAXO-CORE Ruby Core
|
|
2
|
+
|
|
3
|
+
PRAXO-CORE is an embeddable, MIT-licensed agent framework. This gem is its
|
|
4
|
+
native Ruby Core: an agent loop with tool registration and dispatch, policy
|
|
5
|
+
hooks, typed events, memory, checkpoints, context budgets, middleware, JSON
|
|
6
|
+
Schema validation, and plugin manifest parsing.
|
|
7
|
+
|
|
8
|
+
The normative contract is [`spec/`](../../spec/README.md); Go remains the
|
|
9
|
+
reference implementation.
|
|
10
|
+
|
|
11
|
+
- `SPEC_VERSION`: `0.2.0`
|
|
12
|
+
- Core version: `0.1.0`
|
|
13
|
+
- Tier: core
|
|
14
|
+
- License: MIT
|
|
15
|
+
- Dependencies: Ruby standard library only
|
|
16
|
+
|
|
17
|
+
## Scope
|
|
18
|
+
|
|
19
|
+
The Core implements all eleven vector groups: toolcall, JSON Schema, plugin,
|
|
20
|
+
memory, event, stream, checkpoint, policy, context, middleware, and the agent
|
|
21
|
+
loop. It includes a public API snapshot and a dependency-free test runner.
|
|
22
|
+
|
|
23
|
+
## Build and test
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
ruby language/ruby/conformance/run.rb
|
|
27
|
+
ruby language/ruby/api-snapshot.rb --check
|
|
28
|
+
ruby language/ruby/tests/run.rb
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
$LOAD_PATH.unshift('language/ruby/lib')
|
|
35
|
+
require 'praxo'
|
|
36
|
+
|
|
37
|
+
model = Praxo::Agent::StaticModel.new('hello')
|
|
38
|
+
options = Praxo::Agent::AgentOptions.new(model)
|
|
39
|
+
result = Praxo::Agent.run_agent(
|
|
40
|
+
options,
|
|
41
|
+
Praxo::Models.session(id: 'demo'),
|
|
42
|
+
'hello'
|
|
43
|
+
)
|
|
44
|
+
puts result.output
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Tools register on the options object:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
registry = Praxo::Agent::ToolRegistry.new
|
|
51
|
+
registry.register(Praxo::Agent::FuncTool.new('echo', 'Echo arguments',
|
|
52
|
+
->(arguments) { arguments }))
|
|
53
|
+
options.tools = registry
|
|
54
|
+
options.max_steps = 8
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Layout
|
|
58
|
+
|
|
59
|
+
- `lib/praxo/` package source.
|
|
60
|
+
- `conformance/run.rb` vector runner.
|
|
61
|
+
- `api-snapshot.rb` and `api/` public API snapshot.
|
|
62
|
+
- `tests/run.rb` test suite.
|
|
63
|
+
|
|
64
|
+
## Release
|
|
65
|
+
|
|
66
|
+
Tag shape: `ruby/v0.1.0`. The tag targets `SPEC_VERSION` `0.2.0`.
|
data/api/0.1.0.txt
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# PRAXO-CORE Ruby API 0.1.0
|
|
2
|
+
# SPEC_VERSION 0.2.0
|
|
3
|
+
|
|
4
|
+
class AgentOptions
|
|
5
|
+
class CallLimit
|
|
6
|
+
class CheckpointAllowlist
|
|
7
|
+
class ContextBudget
|
|
8
|
+
class Error
|
|
9
|
+
class FilterSink
|
|
10
|
+
class FuncTool
|
|
11
|
+
class MemoryStore
|
|
12
|
+
class MultiSink
|
|
13
|
+
class PathChecker
|
|
14
|
+
class PiiRedactor
|
|
15
|
+
class Runner
|
|
16
|
+
class StaticModel
|
|
17
|
+
class StructuredOutput
|
|
18
|
+
class ToolRegistry
|
|
19
|
+
class ToolSelection
|
|
20
|
+
def action
|
|
21
|
+
def allowed?
|
|
22
|
+
def allows?
|
|
23
|
+
def apply
|
|
24
|
+
def array
|
|
25
|
+
def as_int
|
|
26
|
+
def as_object
|
|
27
|
+
def as_string
|
|
28
|
+
def authorize
|
|
29
|
+
def before_model
|
|
30
|
+
def before_tool
|
|
31
|
+
def bool
|
|
32
|
+
def check
|
|
33
|
+
def check_enum
|
|
34
|
+
def check_keys
|
|
35
|
+
def compact
|
|
36
|
+
def content_block
|
|
37
|
+
def decision
|
|
38
|
+
def emit
|
|
39
|
+
def equals
|
|
40
|
+
def error
|
|
41
|
+
def error_code
|
|
42
|
+
def estimate_tokens
|
|
43
|
+
def event
|
|
44
|
+
def extract_json
|
|
45
|
+
def fallback_model
|
|
46
|
+
def filter
|
|
47
|
+
def generate
|
|
48
|
+
def get
|
|
49
|
+
def has?
|
|
50
|
+
def initialize
|
|
51
|
+
def int
|
|
52
|
+
def invoke
|
|
53
|
+
def invoke_tool
|
|
54
|
+
def marshal
|
|
55
|
+
def memory_record
|
|
56
|
+
def message
|
|
57
|
+
def message_assistant
|
|
58
|
+
def message_text
|
|
59
|
+
def message_tool
|
|
60
|
+
def message_wire
|
|
61
|
+
def parse
|
|
62
|
+
def parse_input_json
|
|
63
|
+
def parse_json_call
|
|
64
|
+
def parse_manifest
|
|
65
|
+
def parse_plain
|
|
66
|
+
def parse_tag
|
|
67
|
+
def parse_toolcall
|
|
68
|
+
def parse_xml
|
|
69
|
+
def plus
|
|
70
|
+
def put
|
|
71
|
+
def redact
|
|
72
|
+
def redact_value
|
|
73
|
+
def register
|
|
74
|
+
def reject
|
|
75
|
+
def request
|
|
76
|
+
def resolve_path
|
|
77
|
+
def response
|
|
78
|
+
def run
|
|
79
|
+
def run_agent
|
|
80
|
+
def run_result
|
|
81
|
+
def search
|
|
82
|
+
def session
|
|
83
|
+
def specs
|
|
84
|
+
def string
|
|
85
|
+
def stringify
|
|
86
|
+
def strip_markup
|
|
87
|
+
def structured_apply
|
|
88
|
+
def text_present
|
|
89
|
+
def tool_call
|
|
90
|
+
def tool_spec
|
|
91
|
+
def truncate_to_tokens
|
|
92
|
+
def unmarshal
|
|
93
|
+
def usage
|
|
94
|
+
def validate
|
|
95
|
+
def visit
|
|
96
|
+
def visit_array
|
|
97
|
+
def visit_boolean
|
|
98
|
+
def visit_number
|
|
99
|
+
def visit_object
|
|
100
|
+
def visit_string
|
|
101
|
+
def within?
|
|
102
|
+
module Agent
|
|
103
|
+
module Json
|
|
104
|
+
module JsonSchema
|
|
105
|
+
module Middleware
|
|
106
|
+
module Models
|
|
107
|
+
module Plugin
|
|
108
|
+
module Praxo
|
|
109
|
+
module Runtime
|
|
110
|
+
module Toolcall
|
data/lib/praxo/agent.rb
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
require_relative 'errors'
|
|
2
|
+
require_relative 'json'
|
|
3
|
+
require_relative 'models'
|
|
4
|
+
require_relative 'runtime'
|
|
5
|
+
|
|
6
|
+
module Praxo
|
|
7
|
+
module Agent
|
|
8
|
+
class FuncTool
|
|
9
|
+
attr_reader :name, :description
|
|
10
|
+
|
|
11
|
+
def initialize(name, description, function)
|
|
12
|
+
@name = name
|
|
13
|
+
@description = description
|
|
14
|
+
@function = function
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def invoke(arguments)
|
|
18
|
+
@function.call(arguments)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class ToolRegistry
|
|
23
|
+
def initialize
|
|
24
|
+
@tools = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def register(tool)
|
|
28
|
+
@tools[tool.name] = tool
|
|
29
|
+
tool
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def get(name)
|
|
33
|
+
Praxo.error('TOOL_NOT_FOUND', "tool not found: #{name}") unless @tools.key?(name)
|
|
34
|
+
@tools[name]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def specs
|
|
38
|
+
@tools.values.map do |tool|
|
|
39
|
+
Models.tool_spec(name: tool.name, description: tool.description)
|
|
40
|
+
end.sort_by(&:name)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class StaticModel
|
|
45
|
+
def initialize(content)
|
|
46
|
+
@content = content.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def generate(_request)
|
|
50
|
+
Models.response(message: Models.message_assistant(@content))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class AgentOptions
|
|
55
|
+
attr_accessor :model, :tools, :events, :policy, :system_prompt, :context,
|
|
56
|
+
:max_steps, :max_tokens, :tool_choice
|
|
57
|
+
|
|
58
|
+
def initialize(model)
|
|
59
|
+
@model = model
|
|
60
|
+
@tools = nil
|
|
61
|
+
@events = nil
|
|
62
|
+
@policy = nil
|
|
63
|
+
@system_prompt = ''
|
|
64
|
+
@context = nil
|
|
65
|
+
@max_steps = 8
|
|
66
|
+
@max_tokens = 0
|
|
67
|
+
@tool_choice = nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class Runner
|
|
72
|
+
def initialize(options)
|
|
73
|
+
@options = options
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def run(session, prompt)
|
|
77
|
+
options = @options
|
|
78
|
+
max_steps = options.max_steps.to_i.positive? ? options.max_steps : 8
|
|
79
|
+
run_id = "run-#{(Time.now.to_f * 1000).to_i}"
|
|
80
|
+
messages = session.messages.dup
|
|
81
|
+
unless options.system_prompt.to_s.empty?
|
|
82
|
+
messages.unshift(Models.message_text(Models::SYSTEM, options.system_prompt))
|
|
83
|
+
end
|
|
84
|
+
messages << Models.message_text(Models::USER, prompt.to_s)
|
|
85
|
+
usage = Models.usage
|
|
86
|
+
emit(options, 'run.started', session.id, run_id)
|
|
87
|
+
begin
|
|
88
|
+
(1..max_steps).each do |step|
|
|
89
|
+
authorize(options, Models.action(kind: 'model.generate', session_id: session.id))
|
|
90
|
+
if options.context
|
|
91
|
+
compacted, ok = options.context.compact(messages)
|
|
92
|
+
Praxo.error('AGENT_CONTEXT_BUDGET', 'context budget') unless ok
|
|
93
|
+
messages = compacted
|
|
94
|
+
end
|
|
95
|
+
request = Models.request(
|
|
96
|
+
messages: messages,
|
|
97
|
+
tools: options.tools ? options.tools.specs : [],
|
|
98
|
+
tool_choice: options.tool_choice
|
|
99
|
+
)
|
|
100
|
+
response = options.model.generate(request)
|
|
101
|
+
usage = usage.plus(response.usage || Models.usage)
|
|
102
|
+
total = usage.input_tokens.to_i + usage.output_tokens.to_i
|
|
103
|
+
if options.max_tokens.to_i.positive? && total > options.max_tokens
|
|
104
|
+
Praxo.error('AGENT_MAX_TOKENS', 'token budget')
|
|
105
|
+
end
|
|
106
|
+
message = response.message
|
|
107
|
+
messages << message
|
|
108
|
+
calls = message.tool_calls || []
|
|
109
|
+
if calls.empty?
|
|
110
|
+
emit(options, 'run.completed', session.id, run_id)
|
|
111
|
+
return Models.run_result(
|
|
112
|
+
run_id: run_id, output: message.content.to_s, steps: step,
|
|
113
|
+
usage: usage
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
calls.each do |call|
|
|
117
|
+
messages << invoke_tool(options, call, run_id, session.id)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
Praxo.error('AGENT_MAX_STEPS', 'maximum steps')
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
emit(options, 'run.failed', session.id, run_id, { 'error' => e.message })
|
|
123
|
+
raise
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def invoke_tool(options, call, run_id, session_id)
|
|
130
|
+
authorize(options, Models.action(
|
|
131
|
+
kind: 'tool.invoke', session_id: session_id,
|
|
132
|
+
tool_name: call.name, arguments: call.arguments
|
|
133
|
+
))
|
|
134
|
+
Praxo.error('TOOL_NOT_FOUND', 'tool is not registered') unless options.tools
|
|
135
|
+
tool = options.tools.get(call.name)
|
|
136
|
+
emit(options, 'tool.call', session_id, run_id,
|
|
137
|
+
{ 'name' => call.name, 'arguments' => call.arguments || {} })
|
|
138
|
+
value = tool.invoke(call.arguments || {})
|
|
139
|
+
Models.message_tool(Json.stringify(value), call.id.to_s)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def authorize(options, action)
|
|
143
|
+
return unless options.policy
|
|
144
|
+
decision = options.policy.call(action)
|
|
145
|
+
Praxo.error('POLICY_ERROR', 'policy denied the action') unless decision&.allowed
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def emit(options, type, session_id, run_id, data = nil)
|
|
149
|
+
return unless options.events
|
|
150
|
+
options.events.emit(Models.event(
|
|
151
|
+
type: type, session_id: session_id, run_id: run_id, data: data
|
|
152
|
+
))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
module_function
|
|
157
|
+
|
|
158
|
+
def run_agent(options, session, prompt)
|
|
159
|
+
Runner.new(options).run(session, prompt)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
data/lib/praxo/errors.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Praxo
|
|
2
|
+
class Error < StandardError
|
|
3
|
+
attr_reader :code
|
|
4
|
+
|
|
5
|
+
def initialize(code, message)
|
|
6
|
+
@code = code
|
|
7
|
+
super(message)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.error(code, message)
|
|
12
|
+
raise Error.new(code, message)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.error_code(error)
|
|
16
|
+
error.is_a?(Error) ? error.code : ''
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/praxo/json.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module Praxo
|
|
4
|
+
module Json
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def parse(text)
|
|
8
|
+
JSON.parse(text)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def stringify(value)
|
|
12
|
+
JSON.generate(value)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get(node, key)
|
|
16
|
+
node.is_a?(Hash) ? node[key] : nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def has?(node, key)
|
|
20
|
+
node.is_a?(Hash) && node.key?(key)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def string(node, key)
|
|
24
|
+
value = get(node, key)
|
|
25
|
+
value.is_a?(String) ? value : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def bool(node, key, fallback = false)
|
|
29
|
+
value = get(node, key)
|
|
30
|
+
value == true || value == false ? value : fallback
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def int(node, key, fallback = 0)
|
|
34
|
+
value = get(node, key)
|
|
35
|
+
value.is_a?(Numeric) ? value.to_i : fallback
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def array(node, key)
|
|
39
|
+
value = get(node, key)
|
|
40
|
+
value.is_a?(Array) ? value : []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def equals(left, right)
|
|
44
|
+
left == right
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require_relative 'json'
|
|
2
|
+
|
|
3
|
+
module Praxo
|
|
4
|
+
module JsonSchema
|
|
5
|
+
ValidationError = Struct.new(:path, :message, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def validate(schema, data)
|
|
10
|
+
errors = []
|
|
11
|
+
visit(schema, data, '$', errors)
|
|
12
|
+
errors
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def visit(schema, data, path, errors)
|
|
16
|
+
return unless schema.is_a?(Hash)
|
|
17
|
+
type = schema['type'].is_a?(String) ? schema['type'] : ''
|
|
18
|
+
if data.nil?
|
|
19
|
+
errors << ValidationError.new(path: path, message: "expected #{type}, got null") unless type.empty?
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
case type
|
|
23
|
+
when 'object' then visit_object(schema, data, path, errors)
|
|
24
|
+
when 'array' then visit_array(schema, data, path, errors)
|
|
25
|
+
when 'string' then visit_string(schema, data, path, errors)
|
|
26
|
+
when 'number', 'integer' then visit_number(schema, type, data, path, errors)
|
|
27
|
+
when 'boolean' then visit_boolean(schema, data, path, errors)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def visit_object(schema, data, path, errors)
|
|
32
|
+
unless data.is_a?(Hash)
|
|
33
|
+
errors << ValidationError.new(path: path, message: 'expected object')
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
Array(schema['required']).each do |name|
|
|
37
|
+
next unless name.is_a?(String)
|
|
38
|
+
unless data.key?(name)
|
|
39
|
+
errors << ValidationError.new(path: "#{path}.#{name}", message: 'required field missing')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
properties = schema['properties']
|
|
43
|
+
if properties.is_a?(Hash)
|
|
44
|
+
properties.each do |name, child|
|
|
45
|
+
visit(child, data[name], "#{path}.#{name}", errors) if data.key?(name)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def visit_array(schema, data, path, errors)
|
|
51
|
+
unless data.is_a?(Array)
|
|
52
|
+
errors << ValidationError.new(path: path, message: 'expected array')
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
items = schema['items']
|
|
56
|
+
return unless items.is_a?(Hash)
|
|
57
|
+
data.each_with_index { |value, index| visit(items, value, "#{path}[#{index}]", errors) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def visit_string(schema, data, path, errors)
|
|
61
|
+
unless data.is_a?(String)
|
|
62
|
+
errors << ValidationError.new(path: path, message: 'expected string')
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
length = data.length
|
|
66
|
+
min = schema['minLength'].is_a?(Numeric) ? schema['minLength'].to_i : 0
|
|
67
|
+
max = schema['maxLength'].is_a?(Numeric) ? schema['maxLength'].to_i : 0
|
|
68
|
+
if min.positive? && length < min
|
|
69
|
+
errors << ValidationError.new(path: path, message: "min length #{min}, got #{length}")
|
|
70
|
+
end
|
|
71
|
+
if max.positive? && length > max
|
|
72
|
+
errors << ValidationError.new(path: path, message: "max length #{max}, got #{length}")
|
|
73
|
+
end
|
|
74
|
+
if schema['pattern'].is_a?(String)
|
|
75
|
+
begin
|
|
76
|
+
regex = Regexp.new(schema['pattern'])
|
|
77
|
+
unless regex.match?(data)
|
|
78
|
+
errors << ValidationError.new(path: path,
|
|
79
|
+
message: "pattern '#{schema['pattern']}' not matched")
|
|
80
|
+
end
|
|
81
|
+
rescue RegexpError
|
|
82
|
+
errors << ValidationError.new(path: path,
|
|
83
|
+
message: "invalid pattern '#{schema['pattern']}'")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
check_enum(schema, data, path, errors)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def visit_number(schema, type, data, path, errors)
|
|
90
|
+
unless data.is_a?(Numeric)
|
|
91
|
+
errors << ValidationError.new(path: path, message: 'expected number')
|
|
92
|
+
return
|
|
93
|
+
end
|
|
94
|
+
if type == 'integer' && data.to_i != data
|
|
95
|
+
errors << ValidationError.new(path: path, message: "expected integer, got #{data}")
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
check_enum(schema, data, path, errors)
|
|
99
|
+
if schema['minimum'].is_a?(Numeric) && schema['minimum'] != 0 && data < schema['minimum']
|
|
100
|
+
errors << ValidationError.new(path: path, message: 'value below minimum')
|
|
101
|
+
end
|
|
102
|
+
if schema['maximum'].is_a?(Numeric) && schema['maximum'] != 0 && data > schema['maximum']
|
|
103
|
+
errors << ValidationError.new(path: path, message: 'value above maximum')
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def visit_boolean(schema, data, path, errors)
|
|
108
|
+
unless data == true || data == false
|
|
109
|
+
errors << ValidationError.new(path: path, message: 'expected boolean')
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
check_enum(schema, data, path, errors)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def check_enum(schema, data, path, errors)
|
|
116
|
+
values = schema['enum']
|
|
117
|
+
return unless values.is_a?(Array) && !values.empty?
|
|
118
|
+
return if values.any? { |value| Json.equals(value, data) }
|
|
119
|
+
errors << ValidationError.new(path: path, message: 'value not in enum')
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require_relative 'errors'
|
|
2
|
+
require_relative 'json'
|
|
3
|
+
require_relative 'jsonschema'
|
|
4
|
+
require_relative 'models'
|
|
5
|
+
|
|
6
|
+
module Praxo
|
|
7
|
+
module Middleware
|
|
8
|
+
class PiiRedactor
|
|
9
|
+
RULES = [
|
|
10
|
+
[/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/, '[REDACTED:email]'],
|
|
11
|
+
[/\b\d{3}-\d{2}-\d{4}\b/, '[REDACTED:ssn]'],
|
|
12
|
+
[/\b(?:\d{4}[ -]?){3}\d{4}\b/, '[REDACTED:credit_card]']
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def redact(text)
|
|
16
|
+
output = text.to_s.dup
|
|
17
|
+
RULES.each { |pattern, replacement| output.gsub!(pattern, replacement) }
|
|
18
|
+
output
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def redact_value(value)
|
|
22
|
+
case value
|
|
23
|
+
when String then redact(value)
|
|
24
|
+
when Array then value.map { |item| redact_value(item) }
|
|
25
|
+
when Hash then value.transform_values { |item| redact_value(item) }
|
|
26
|
+
else value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class StructuredOutput
|
|
32
|
+
def initialize(schema:, max_repairs: 0, repairer: nil)
|
|
33
|
+
@schema = schema
|
|
34
|
+
@max_repairs = max_repairs
|
|
35
|
+
@repairer = repairer
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def apply(text)
|
|
39
|
+
output = text.to_s
|
|
40
|
+
(0..@max_repairs).each do |attempt|
|
|
41
|
+
begin
|
|
42
|
+
data = Middleware.extract_json(output)
|
|
43
|
+
return [output, nil] if JsonSchema.validate(@schema, data).empty?
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
break if attempt == @max_repairs && @repairer.nil?
|
|
48
|
+
break unless @repairer
|
|
49
|
+
repaired = @repairer.call(output)
|
|
50
|
+
break if repaired.nil? || repaired.strip.empty?
|
|
51
|
+
output = repaired
|
|
52
|
+
end
|
|
53
|
+
[nil, 'AGENT_STRUCTURED_OUTPUT']
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class CallLimit
|
|
58
|
+
def initialize(model_limit:, tool_limit:)
|
|
59
|
+
@model_limit = model_limit
|
|
60
|
+
@tool_limit = tool_limit
|
|
61
|
+
@models = 0
|
|
62
|
+
@tools = 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def before_model
|
|
66
|
+
@models += 1
|
|
67
|
+
if @model_limit.positive? && @models > @model_limit
|
|
68
|
+
Praxo.error('AGENT_MODEL_CALL_LIMIT', 'model call limit')
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def before_tool
|
|
73
|
+
@tools += 1
|
|
74
|
+
if @tool_limit.positive? && @tools > @tool_limit
|
|
75
|
+
Praxo.error('AGENT_TOOL_CALL_LIMIT', 'tool call limit')
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class ToolSelection
|
|
81
|
+
def initialize(selected)
|
|
82
|
+
@selected = selected.to_h { |name| [name, true] }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def filter(tools)
|
|
86
|
+
tools.select { |tool| @selected[tool.name] }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def reject(name)
|
|
90
|
+
Praxo.error('AGENT_TOOL_NOT_SELECTED', 'tool not selected') unless @selected[name]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
module_function
|
|
95
|
+
|
|
96
|
+
def extract_json(text)
|
|
97
|
+
value = text.to_s.strip
|
|
98
|
+
if value.start_with?('```')
|
|
99
|
+
newline = value.index("\n")
|
|
100
|
+
value = value[(newline + 1)..] if newline
|
|
101
|
+
value = value.gsub(/\A`+/, '').gsub(/`+\z/, '').strip
|
|
102
|
+
end
|
|
103
|
+
start = [value.index('{'), value.index('[')].compact.min
|
|
104
|
+
raise Praxo::Error.new('AGENT_STRUCTURED_OUTPUT', 'output does not contain JSON') if start.nil?
|
|
105
|
+
Json.parse(value[start..])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def redact(text)
|
|
109
|
+
PiiRedactor.new.redact(text)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def redact_value(value)
|
|
113
|
+
PiiRedactor.new.redact_value(value)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def structured_apply(schema, text, max_repairs = 0, repairer = nil)
|
|
117
|
+
StructuredOutput.new(schema: schema, max_repairs: max_repairs, repairer: repairer).apply(text)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fallback_model(fallback)
|
|
121
|
+
lambda do |next_handler|
|
|
122
|
+
lambda do |request|
|
|
123
|
+
begin
|
|
124
|
+
next_handler.call(request)
|
|
125
|
+
rescue StandardError
|
|
126
|
+
raise unless fallback
|
|
127
|
+
fallback.call(request)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/praxo/models.rb
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
module Praxo
|
|
2
|
+
module Models
|
|
3
|
+
SYSTEM = 'system'
|
|
4
|
+
USER = 'user'
|
|
5
|
+
ASSISTANT = 'assistant'
|
|
6
|
+
TOOL = 'tool'
|
|
7
|
+
|
|
8
|
+
STREAM_KINDS = %w[values updates messages tasks debug checkpoint].freeze
|
|
9
|
+
|
|
10
|
+
ContentBlock = Struct.new(:type, :text, :image_url, :data, keyword_init: true)
|
|
11
|
+
ToolCall = Struct.new(:id, :name, :arguments, keyword_init: true)
|
|
12
|
+
ToolSpec = Struct.new(:name, :description, :parameters, :risk, keyword_init: true)
|
|
13
|
+
Message = Struct.new(:role, :content, :content_blocks, :tool_calls, :tool_id, keyword_init: true)
|
|
14
|
+
Usage = Struct.new(:input_tokens, :output_tokens, :cached_tokens, keyword_init: true) do
|
|
15
|
+
def plus(other)
|
|
16
|
+
Usage.new(
|
|
17
|
+
input_tokens: input_tokens.to_i + other.input_tokens.to_i,
|
|
18
|
+
output_tokens: output_tokens.to_i + other.output_tokens.to_i,
|
|
19
|
+
cached_tokens: cached_tokens.to_i + other.cached_tokens.to_i
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
Request = Struct.new(:messages, :tools, :tool_choice, :response_format, :temperature, keyword_init: true)
|
|
24
|
+
Response = Struct.new(:message, :usage, :finish_reason, keyword_init: true)
|
|
25
|
+
Session = Struct.new(:id, :messages, keyword_init: true)
|
|
26
|
+
RunResult = Struct.new(:run_id, :output, :steps, :usage, keyword_init: true)
|
|
27
|
+
MemoryRecord = Struct.new(:id, :frame, :content, :created_at, keyword_init: true)
|
|
28
|
+
PathGrant = Struct.new(:root, :access, keyword_init: true)
|
|
29
|
+
Action = Struct.new(:kind, :session_id, :tool_name, :arguments, keyword_init: true)
|
|
30
|
+
Decision = Struct.new(:allowed, :reason, keyword_init: true)
|
|
31
|
+
Event = Struct.new(:type, :session_id, :run_id, :data, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def content_block(**args)
|
|
36
|
+
ContentBlock.new(
|
|
37
|
+
type: args[:type] || 'text',
|
|
38
|
+
text: args[:text],
|
|
39
|
+
image_url: args[:image_url],
|
|
40
|
+
data: args[:data]
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def tool_call(**args)
|
|
45
|
+
ToolCall.new(
|
|
46
|
+
id: args[:id] || '',
|
|
47
|
+
name: args[:name] || '',
|
|
48
|
+
arguments: args[:arguments] || {}
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def message(**args)
|
|
53
|
+
Message.new(
|
|
54
|
+
role: args[:role] || USER,
|
|
55
|
+
content: args[:content],
|
|
56
|
+
content_blocks: args[:content_blocks] || [],
|
|
57
|
+
tool_calls: args[:tool_calls] || [],
|
|
58
|
+
tool_id: args[:tool_id]
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def message_text(role, content)
|
|
63
|
+
message(role: role, content: content)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def message_assistant(content, tool_calls = [])
|
|
67
|
+
message(role: ASSISTANT, content: content, tool_calls: tool_calls)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def message_tool(content, tool_id)
|
|
71
|
+
message(role: TOOL, content: content, tool_id: tool_id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def tool_spec(**args)
|
|
75
|
+
ToolSpec.new(
|
|
76
|
+
name: args[:name] || '',
|
|
77
|
+
description: args[:description] || '',
|
|
78
|
+
parameters: args[:parameters],
|
|
79
|
+
risk: args[:risk]
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def usage(input_tokens: 0, output_tokens: 0, cached_tokens: 0)
|
|
84
|
+
Usage.new(input_tokens: input_tokens, output_tokens: output_tokens,
|
|
85
|
+
cached_tokens: cached_tokens)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def request(**args)
|
|
89
|
+
Request.new(
|
|
90
|
+
messages: args[:messages] || [],
|
|
91
|
+
tools: args[:tools] || [],
|
|
92
|
+
tool_choice: args[:tool_choice],
|
|
93
|
+
response_format: args[:response_format],
|
|
94
|
+
temperature: args[:temperature] || 0
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def response(message:, usage: nil, finish_reason: nil)
|
|
99
|
+
Response.new(message: message, usage: usage || Praxo::Models.usage,
|
|
100
|
+
finish_reason: finish_reason)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def session(id:, messages: [])
|
|
104
|
+
Session.new(id: id, messages: messages)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def run_result(run_id:, output:, steps:, usage:)
|
|
108
|
+
RunResult.new(run_id: run_id, output: output, steps: steps, usage: usage)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def memory_record(**args)
|
|
112
|
+
MemoryRecord.new(
|
|
113
|
+
id: args[:id] || '',
|
|
114
|
+
frame: args[:frame] || '',
|
|
115
|
+
content: args[:content] || '',
|
|
116
|
+
created_at: args[:created_at]
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def event(type:, session_id:, run_id: nil, data: nil)
|
|
121
|
+
Event.new(type: type, session_id: session_id, run_id: run_id, data: data)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def action(kind:, session_id:, tool_name: nil, arguments: nil)
|
|
125
|
+
Action.new(kind: kind, session_id: session_id, tool_name: tool_name,
|
|
126
|
+
arguments: arguments)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def decision(allowed:, reason: '')
|
|
130
|
+
Decision.new(allowed: allowed, reason: reason)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def message_wire(message)
|
|
134
|
+
out = { 'role' => message.role }
|
|
135
|
+
out['content'] = message.content unless message.content.nil?
|
|
136
|
+
unless message.content_blocks.nil? || message.content_blocks.empty?
|
|
137
|
+
out['contentBlocks'] = message.content_blocks.map do |block|
|
|
138
|
+
wire = { 'type' => block.type }
|
|
139
|
+
wire['text'] = block.text unless block.text.nil?
|
|
140
|
+
wire['image_url'] = block.image_url unless block.image_url.nil?
|
|
141
|
+
wire['data'] = block.data unless block.data.nil?
|
|
142
|
+
wire
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
unless message.tool_calls.nil? || message.tool_calls.empty?
|
|
146
|
+
out['toolCalls'] = message.tool_calls.map do |call|
|
|
147
|
+
{ 'id' => call.id, 'name' => call.name, 'arguments' => call.arguments }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
out['toolId'] = message.tool_id unless message.tool_id.nil?
|
|
151
|
+
out
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
data/lib/praxo/plugin.rb
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
require_relative 'errors'
|
|
2
|
+
require_relative 'json'
|
|
3
|
+
|
|
4
|
+
module Praxo
|
|
5
|
+
module Plugin
|
|
6
|
+
PluginManifest = Struct.new(
|
|
7
|
+
:schema_version, :id, :version, :kind, :entrypoint, :protocol,
|
|
8
|
+
:capabilities, :permissions, :limits, :isolation, :health, keyword_init: true
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
KINDS = %w[
|
|
12
|
+
tool.provider.v1 model.provider.v1 policy.provider.v1
|
|
13
|
+
memory.provider.v1 sandbox.provider.v1
|
|
14
|
+
].freeze
|
|
15
|
+
TOP_KEYS = %w[
|
|
16
|
+
schemaVersion id version kind entrypoint protocol capabilities permissions
|
|
17
|
+
limits isolation health signature sbom
|
|
18
|
+
].freeze
|
|
19
|
+
ENTRYPOINT_KEYS = %w[type command module image args dir].freeze
|
|
20
|
+
PERMISSION_KEYS = %w[filesystem network env].freeze
|
|
21
|
+
LIMIT_KEYS = %w[memoryMB cpuMillis timeoutMs maxInflight].freeze
|
|
22
|
+
HEALTH_KEYS = %w[method intervalMs].freeze
|
|
23
|
+
SIGNATURE_KEYS = %w[algorithm keyId].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def parse_manifest(raw)
|
|
28
|
+
Praxo.error('PLUGIN_INVALID', 'plugin: manifest is empty') if raw.nil? || raw.strip.empty?
|
|
29
|
+
begin
|
|
30
|
+
root = JSON.parse(raw)
|
|
31
|
+
rescue JSON::ParserError => e
|
|
32
|
+
Praxo.error('PLUGIN_INVALID', "plugin: manifest: #{e.message}")
|
|
33
|
+
end
|
|
34
|
+
Praxo.error('PLUGIN_INVALID', 'plugin: manifest must be an object') unless root.is_a?(Hash)
|
|
35
|
+
check_keys(root, TOP_KEYS, 'manifest')
|
|
36
|
+
schema_version = as_int(root['schemaVersion'], 'schemaVersion')
|
|
37
|
+
if schema_version != 1
|
|
38
|
+
Praxo.error('PLUGIN_INVALID',
|
|
39
|
+
"plugin: manifest: schemaVersion = #{schema_version}, want 1")
|
|
40
|
+
end
|
|
41
|
+
id = as_string(root['id'], 'id')
|
|
42
|
+
unless id.match?(/\A(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/)
|
|
43
|
+
Praxo.error('PLUGIN_INVALID', "plugin: manifest: id #{id} is not reverse-DNS")
|
|
44
|
+
end
|
|
45
|
+
version = as_string(root['version'], 'version')
|
|
46
|
+
unless version.match?(/\A(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/)
|
|
47
|
+
Praxo.error('PLUGIN_INVALID', "plugin: manifest: version #{version} is not semver")
|
|
48
|
+
end
|
|
49
|
+
kind = as_string(root['kind'], 'kind')
|
|
50
|
+
unless KINDS.include?(kind)
|
|
51
|
+
Praxo.error('PLUGIN_INVALID', "plugin: manifest: kind #{kind} is not a known contract")
|
|
52
|
+
end
|
|
53
|
+
protocol = as_string(root['protocol'], 'protocol')
|
|
54
|
+
unless protocol == 'jsonrpc-2'
|
|
55
|
+
Praxo.error('PLUGIN_INVALID', "plugin: manifest: protocol = #{protocol}, want jsonrpc-2")
|
|
56
|
+
end
|
|
57
|
+
entrypoint = as_object(root['entrypoint'], 'entrypoint')
|
|
58
|
+
check_keys(entrypoint, ENTRYPOINT_KEYS, 'entrypoint')
|
|
59
|
+
entry_type = as_string(entrypoint['type'], 'entrypoint.type')
|
|
60
|
+
required = case entry_type
|
|
61
|
+
when 'process' then 'command'
|
|
62
|
+
when 'wasm' then 'module'
|
|
63
|
+
when 'container' then 'image'
|
|
64
|
+
else
|
|
65
|
+
Praxo.error('PLUGIN_INVALID',
|
|
66
|
+
"plugin: manifest: unknown entrypoint type #{entry_type}")
|
|
67
|
+
end
|
|
68
|
+
unless text_present(entrypoint[required])
|
|
69
|
+
Praxo.error('PLUGIN_INVALID',
|
|
70
|
+
"plugin: manifest: #{entry_type} entrypoint requires #{required}")
|
|
71
|
+
end
|
|
72
|
+
isolation = as_string(root['isolation'], 'isolation')
|
|
73
|
+
unless isolation == entry_type
|
|
74
|
+
Praxo.error('PLUGIN_INVALID',
|
|
75
|
+
"plugin: manifest: isolation #{isolation} does not match entrypoint #{entry_type}")
|
|
76
|
+
end
|
|
77
|
+
permissions = as_object(root['permissions'], 'permissions')
|
|
78
|
+
check_keys(permissions, PERMISSION_KEYS, 'permissions')
|
|
79
|
+
limits = as_object(root['limits'], 'limits')
|
|
80
|
+
check_keys(limits, LIMIT_KEYS, 'limits')
|
|
81
|
+
limits.each do |key, value|
|
|
82
|
+
Praxo.error('PLUGIN_INVALID', 'plugin: manifest: limits must not be negative') if as_int(value, "limits.#{key}").negative?
|
|
83
|
+
end
|
|
84
|
+
health = as_object(root['health'], 'health')
|
|
85
|
+
check_keys(health, HEALTH_KEYS, 'health')
|
|
86
|
+
if root.key?('signature')
|
|
87
|
+
signature = as_object(root['signature'], 'signature')
|
|
88
|
+
check_keys(signature, SIGNATURE_KEYS, 'signature')
|
|
89
|
+
unless text_present(signature['algorithm']) && text_present(signature['keyId'])
|
|
90
|
+
Praxo.error('PLUGIN_INVALID',
|
|
91
|
+
'plugin: manifest: signature requires algorithm and keyId')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
capabilities = []
|
|
95
|
+
if root.key?('capabilities')
|
|
96
|
+
unless root['capabilities'].is_a?(Array)
|
|
97
|
+
Praxo.error('PLUGIN_INVALID', 'plugin: manifest: capabilities must be an array')
|
|
98
|
+
end
|
|
99
|
+
root['capabilities'].each do |item|
|
|
100
|
+
capability = as_string(item, 'capabilities')
|
|
101
|
+
Praxo.error('PLUGIN_INVALID', 'plugin: manifest: capability must not be empty') if capability.empty?
|
|
102
|
+
Praxo.error('PLUGIN_INVALID', 'plugin: manifest: duplicate capability') if capabilities.include?(capability)
|
|
103
|
+
capabilities << capability
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
PluginManifest.new(
|
|
107
|
+
schema_version: schema_version, id: id, version: version, kind: kind,
|
|
108
|
+
entrypoint: entrypoint, protocol: protocol, capabilities: capabilities,
|
|
109
|
+
permissions: permissions, limits: limits, isolation: isolation, health: health
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def check_keys(value, allowed, where)
|
|
114
|
+
value.each_key do |key|
|
|
115
|
+
unless allowed.include?(key)
|
|
116
|
+
Praxo.error('PLUGIN_INVALID', "plugin: manifest: unknown field #{key} in #{where}")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def as_object(value, where)
|
|
122
|
+
return {} if value.nil?
|
|
123
|
+
unless value.is_a?(Hash)
|
|
124
|
+
Praxo.error('PLUGIN_INVALID', "plugin: manifest: #{where} must be an object")
|
|
125
|
+
end
|
|
126
|
+
value
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def as_string(value, where)
|
|
130
|
+
unless value.is_a?(String)
|
|
131
|
+
Praxo.error('PLUGIN_INVALID', "plugin: manifest: #{where} must be a string")
|
|
132
|
+
end
|
|
133
|
+
value
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def as_int(value, where)
|
|
137
|
+
unless value.is_a?(Numeric) && value.to_i == value
|
|
138
|
+
Praxo.error('PLUGIN_INVALID', "plugin: manifest: #{where} must be an integer")
|
|
139
|
+
end
|
|
140
|
+
value.to_i
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def text_present(value)
|
|
144
|
+
value.is_a?(String) && !value.empty?
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
require_relative 'errors'
|
|
2
|
+
require_relative 'json'
|
|
3
|
+
require_relative 'models'
|
|
4
|
+
|
|
5
|
+
module Praxo
|
|
6
|
+
module Runtime
|
|
7
|
+
class MultiSink
|
|
8
|
+
def initialize(sinks)
|
|
9
|
+
@sinks = sinks
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def emit(event)
|
|
13
|
+
@sinks.each { |sink| sink.emit(event) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class FilterSink
|
|
18
|
+
def initialize(sink, predicate)
|
|
19
|
+
@sink = sink
|
|
20
|
+
@predicate = predicate
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def emit(event)
|
|
24
|
+
@sink.emit(event) if @predicate.call(event)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class MemoryStore
|
|
29
|
+
def initialize
|
|
30
|
+
@records = Hash.new { |hash, key| hash[key] = [] }
|
|
31
|
+
@sequence = 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def put(session_id, record)
|
|
35
|
+
if record.id.nil? || record.id.empty?
|
|
36
|
+
record.id = "mem-#{(Time.now.to_f * 1000).to_i}-#{@sequence}"
|
|
37
|
+
@sequence += 1
|
|
38
|
+
end
|
|
39
|
+
if record.created_at.nil?
|
|
40
|
+
record.created_at = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
41
|
+
end
|
|
42
|
+
@records[session_id] << record
|
|
43
|
+
record
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def search(session_id, query, limit)
|
|
47
|
+
needle = query.to_s.strip.downcase
|
|
48
|
+
matches = @records[session_id].select do |record|
|
|
49
|
+
needle.empty? || record.content.to_s.downcase.include?(needle)
|
|
50
|
+
end
|
|
51
|
+
matches.sort_by! { |record| record.created_at.to_s }.reverse!
|
|
52
|
+
limit.positive? ? matches.first(limit) : matches
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class CheckpointAllowlist
|
|
57
|
+
def initialize
|
|
58
|
+
@registered = {}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def register(type)
|
|
62
|
+
Praxo.error('CHECKPOINT_ERROR', 'checkpoint: type is required') if type.nil? || type.empty?
|
|
63
|
+
@registered[type] = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def marshal(type, value)
|
|
67
|
+
unless @registered[type]
|
|
68
|
+
Praxo.error('CHECKPOINT_TYPE_NOT_ALLOWED',
|
|
69
|
+
"checkpoint: payload type not allowed: #{type}")
|
|
70
|
+
end
|
|
71
|
+
Json.stringify({ 'type' => type, 'data' => value })
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def unmarshal(data)
|
|
75
|
+
envelope = Json.parse(data)
|
|
76
|
+
Praxo.error('CHECKPOINT_ERROR', 'checkpoint: envelope must be an object') unless envelope.is_a?(Hash)
|
|
77
|
+
type = envelope['type'].is_a?(String) ? envelope['type'] : ''
|
|
78
|
+
unless @registered[type]
|
|
79
|
+
Praxo.error('CHECKPOINT_TYPE_NOT_ALLOWED',
|
|
80
|
+
"checkpoint: payload type not allowed: #{type}")
|
|
81
|
+
end
|
|
82
|
+
envelope['data']
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
class PathChecker
|
|
87
|
+
def initialize(grants)
|
|
88
|
+
@grants = grants.map do |grant|
|
|
89
|
+
Models::PathGrant.new(root: resolve_path(grant.root), access: grant.access || 'read')
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def check(target, need)
|
|
94
|
+
unless %w[read write readwrite].include?(need)
|
|
95
|
+
Praxo.error('POLICY_ERROR', 'policy: invalid access')
|
|
96
|
+
end
|
|
97
|
+
return Models::Decision.new(allowed: false, reason: 'no allowed roots') if @grants.empty?
|
|
98
|
+
resolved = resolve_path(target)
|
|
99
|
+
matched = nil
|
|
100
|
+
@grants.each do |grant|
|
|
101
|
+
next unless within?(grant.root, resolved)
|
|
102
|
+
matched = grant.root
|
|
103
|
+
return Models::Decision.new(allowed: true, reason: "path within #{grant.root}") if allows?(grant.access, need)
|
|
104
|
+
end
|
|
105
|
+
Models::Decision.new(allowed: false, reason: matched ? 'insufficient grant' : 'outside roots')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def allowed?(target, need)
|
|
109
|
+
check(target, need).allowed
|
|
110
|
+
rescue Error
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def resolve_path(value)
|
|
117
|
+
absolute = File.expand_path(value.to_s)
|
|
118
|
+
return File.realpath(absolute) if File.exist?(absolute)
|
|
119
|
+
parent = File.dirname(absolute)
|
|
120
|
+
return absolute if parent == absolute
|
|
121
|
+
File.join(resolve_path(parent), File.basename(absolute))
|
|
122
|
+
rescue StandardError
|
|
123
|
+
absolute
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def within?(root, target)
|
|
127
|
+
root = root.tr('\\', '/').sub(%r{/+\z}, '')
|
|
128
|
+
target = target.tr('\\', '/').sub(%r{/+\z}, '')
|
|
129
|
+
if RbConfig::CONFIG['host_os'].match?(/mswin|mingw|cygwin/)
|
|
130
|
+
root = root.downcase
|
|
131
|
+
target = target.downcase
|
|
132
|
+
end
|
|
133
|
+
target == root || target.start_with?("#{root}/")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def allows?(grant, need)
|
|
137
|
+
return true if need == 'read' && %w[read readwrite].include?(grant)
|
|
138
|
+
return true if need == 'write' && %w[write readwrite].include?(grant)
|
|
139
|
+
return true if need == 'readwrite' && grant == 'readwrite'
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class ContextBudget
|
|
145
|
+
attr_reader :max_tokens, :keep_recent
|
|
146
|
+
|
|
147
|
+
def initialize(max_tokens: 0, keep_recent: 0)
|
|
148
|
+
@max_tokens = max_tokens
|
|
149
|
+
@keep_recent = keep_recent
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def compact(messages)
|
|
153
|
+
max = @max_tokens
|
|
154
|
+
return [messages, true] if max <= 0 || Runtime.estimate_tokens(messages) <= max
|
|
155
|
+
system = []
|
|
156
|
+
rest = []
|
|
157
|
+
messages.each do |message|
|
|
158
|
+
if message.role == Models::SYSTEM
|
|
159
|
+
next if message.content.to_s.start_with?('[context compacted:')
|
|
160
|
+
system << message
|
|
161
|
+
else
|
|
162
|
+
rest << message
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
if Runtime.estimate_tokens(system) > max
|
|
166
|
+
return [[], false] if system.empty?
|
|
167
|
+
first = system[0].dup
|
|
168
|
+
first.content = Runtime.truncate_to_tokens(system[0].content.to_s, max - 4)
|
|
169
|
+
system = [first]
|
|
170
|
+
return [[], false] if Runtime.estimate_tokens(system) > max
|
|
171
|
+
end
|
|
172
|
+
keep = @keep_recent.positive? ? @keep_recent : 4
|
|
173
|
+
start = [rest.length - keep, 0].max
|
|
174
|
+
start -= 1 while start.positive? && rest[start].role == Models::TOOL
|
|
175
|
+
marker = Models.message_text(
|
|
176
|
+
Models::SYSTEM,
|
|
177
|
+
"[context compacted: #{start} older messages omitted]"
|
|
178
|
+
)
|
|
179
|
+
kept = rest[start..] || []
|
|
180
|
+
build = lambda do
|
|
181
|
+
output = system.dup
|
|
182
|
+
output << marker if start.positive?
|
|
183
|
+
output.concat(kept)
|
|
184
|
+
output
|
|
185
|
+
end
|
|
186
|
+
output = build.call
|
|
187
|
+
while Runtime.estimate_tokens(output) > max && kept.length > 1
|
|
188
|
+
break if kept[1].role == Models::TOOL
|
|
189
|
+
kept.shift
|
|
190
|
+
output = build.call
|
|
191
|
+
end
|
|
192
|
+
[output, Runtime.estimate_tokens(output) <= max]
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
module_function
|
|
197
|
+
|
|
198
|
+
def estimate_tokens(messages)
|
|
199
|
+
total = 0
|
|
200
|
+
messages.each do |message|
|
|
201
|
+
total += 4 + message.content.to_s.length / 4
|
|
202
|
+
total += message.tool_id.to_s.length / 4
|
|
203
|
+
(message.content_blocks || []).each do |block|
|
|
204
|
+
total += 4 + block.text.to_s.length / 4
|
|
205
|
+
total += block.image_url.to_s.length / 4
|
|
206
|
+
total += block.data.to_s.length / 4
|
|
207
|
+
end
|
|
208
|
+
(message.tool_calls || []).each do |call|
|
|
209
|
+
total += 4 + call.name.to_s.length / 4
|
|
210
|
+
total += Json.stringify(call.arguments || {}).length / 4
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
total
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def truncate_to_tokens(value, tokens)
|
|
217
|
+
return '' if tokens <= 0
|
|
218
|
+
limit = tokens * 4
|
|
219
|
+
return value if value.length <= limit
|
|
220
|
+
return value[0, limit] if limit <= 16
|
|
221
|
+
"#{value[0, limit - 16]}\n[truncated]"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require_relative 'json'
|
|
2
|
+
|
|
3
|
+
module Praxo
|
|
4
|
+
module Toolcall
|
|
5
|
+
ParsedToolCall = Struct.new(
|
|
6
|
+
:call_id, :name, :input, :operation, :resource, :risk, :reason,
|
|
7
|
+
:idempotency_key, keyword_init: true
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def parse_toolcall(content)
|
|
13
|
+
call = parse_json_call(content) || parse_xml(content) || parse_tag(content) ||
|
|
14
|
+
parse_plain(content)
|
|
15
|
+
return nil unless call
|
|
16
|
+
call.call_id = "tc-#{(Time.now.to_f * 1000).to_i}"
|
|
17
|
+
call
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def strip_markup(content)
|
|
21
|
+
output = content.dup
|
|
22
|
+
output.gsub!(%r{<tool_call\s+name="[^"]+"\s*>.*?(?:</tool_call>|$)}m, '')
|
|
23
|
+
output.gsub!(%r{<tool_call_invoke>.*?(?:</tool_call_invoke>|$)}m, '')
|
|
24
|
+
output.gsub!(%r{<[a-zA-Z][\w-]*(?:\.[\w-]+)+>\s*\{.*?(?:</[a-zA-Z][\w.-]*>|$)}m, '')
|
|
25
|
+
output.gsub!(/\n{3,}/, "\n\n")
|
|
26
|
+
output.strip
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parse_input_json(raw)
|
|
30
|
+
begin
|
|
31
|
+
value = JSON.parse(raw.strip)
|
|
32
|
+
return value if value.is_a?(Hash)
|
|
33
|
+
rescue JSON::ParserError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
escaped = raw.gsub('\\', '\\\\\\\\')
|
|
37
|
+
begin
|
|
38
|
+
value = JSON.parse(escaped.strip)
|
|
39
|
+
return value if value.is_a?(Hash)
|
|
40
|
+
rescue JSON::ParserError
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_json_call(content)
|
|
47
|
+
value = JSON.parse(content)
|
|
48
|
+
return nil unless value.is_a?(Hash) && value['name'].is_a?(String) &&
|
|
49
|
+
!value['name'].empty?
|
|
50
|
+
ParsedToolCall.new(
|
|
51
|
+
name: value['name'],
|
|
52
|
+
input: value['input'].is_a?(Hash) ? value['input'] : {},
|
|
53
|
+
operation: value['operation'] || '',
|
|
54
|
+
resource: value['resource'] || '',
|
|
55
|
+
risk: value['risk'] || '',
|
|
56
|
+
reason: value['reason'] || '',
|
|
57
|
+
idempotency_key: value['idempotency_key'] || ''
|
|
58
|
+
)
|
|
59
|
+
rescue JSON::ParserError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse_xml(content)
|
|
64
|
+
match = nil
|
|
65
|
+
content.scan(%r{<tool_call\s+name="([^"]+)"\s*>\s*(.*?)\s*</tool_call>}m) do |name, raw|
|
|
66
|
+
match = [name, raw]
|
|
67
|
+
end
|
|
68
|
+
return nil unless match
|
|
69
|
+
input = parse_input_json(match[1])
|
|
70
|
+
return nil unless input
|
|
71
|
+
ParsedToolCall.new(name: match[0], input: input)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_tag(content)
|
|
75
|
+
match = nil
|
|
76
|
+
content.scan(%r{<([a-zA-Z][\w-]*(?:\.[\w-]+)+)>\s*(\{.*?)\s*(?:</[a-zA-Z][\w.-]*>|$)}m) do |name, raw|
|
|
77
|
+
match = [name, raw]
|
|
78
|
+
end
|
|
79
|
+
return nil unless match
|
|
80
|
+
input = parse_input_json(match[1])
|
|
81
|
+
return nil unless input
|
|
82
|
+
ParsedToolCall.new(name: match[0], input: input)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_plain(content)
|
|
86
|
+
match = nil
|
|
87
|
+
content.scan(/(?:^|\n)\s*([a-zA-Z][\w.-]*)\s+(\{.*\})\s*(?:\n|$)/m) do |name, raw|
|
|
88
|
+
match = [name, raw]
|
|
89
|
+
end
|
|
90
|
+
return nil unless match && match[0].include?('.')
|
|
91
|
+
input = parse_input_json(match[1])
|
|
92
|
+
return nil unless input
|
|
93
|
+
ParsedToolCall.new(name: match[0], input: input)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/praxo.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
require_relative 'praxo/errors'
|
|
2
|
+
require_relative 'praxo/json'
|
|
3
|
+
require_relative 'praxo/models'
|
|
4
|
+
require_relative 'praxo/toolcall'
|
|
5
|
+
require_relative 'praxo/jsonschema'
|
|
6
|
+
require_relative 'praxo/plugin'
|
|
7
|
+
require_relative 'praxo/runtime'
|
|
8
|
+
require_relative 'praxo/middleware'
|
|
9
|
+
require_relative 'praxo/agent'
|
metadata
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: praxo-core
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- AxisVela
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Native Ruby Core for the PRAXO-CORE agent framework: tools, policy,
|
|
13
|
+
events, memory, context, middleware, checkpoints, and an agent loop.'
|
|
14
|
+
email:
|
|
15
|
+
- releases@axisvela.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- api/0.1.0.txt
|
|
23
|
+
- lib/praxo.rb
|
|
24
|
+
- lib/praxo/agent.rb
|
|
25
|
+
- lib/praxo/errors.rb
|
|
26
|
+
- lib/praxo/json.rb
|
|
27
|
+
- lib/praxo/jsonschema.rb
|
|
28
|
+
- lib/praxo/middleware.rb
|
|
29
|
+
- lib/praxo/models.rb
|
|
30
|
+
- lib/praxo/plugin.rb
|
|
31
|
+
- lib/praxo/runtime.rb
|
|
32
|
+
- lib/praxo/toolcall.rb
|
|
33
|
+
homepage: https://axisvela.github.io/praxo-core/
|
|
34
|
+
licenses:
|
|
35
|
+
- MIT
|
|
36
|
+
metadata:
|
|
37
|
+
source_code_uri: https://github.com/axisvela/praxo-core
|
|
38
|
+
bug_tracker_uri: https://github.com/axisvela/praxo-core/issues
|
|
39
|
+
documentation_uri: https://axisvela.github.io/praxo-core/languages/ruby/
|
|
40
|
+
rdoc_options: []
|
|
41
|
+
require_paths:
|
|
42
|
+
- lib
|
|
43
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.1'
|
|
48
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '0'
|
|
53
|
+
requirements: []
|
|
54
|
+
rubygems_version: 3.6.9
|
|
55
|
+
specification_version: 4
|
|
56
|
+
summary: Embeddable MIT-licensed agent framework for Ruby
|
|
57
|
+
test_files: []
|