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 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
@@ -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
@@ -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
@@ -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
@@ -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: []