prescient 0.6.0 → 0.8.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -268
  3. data/CHANGELOG.md +59 -0
  4. data/INTEGRATION_GUIDE.md +19 -1
  5. data/README.md +369 -21
  6. data/Steepfile +12 -12
  7. data/db/migrate/001_create_prescient_tables.rb +15 -16
  8. data/docker-compose.yml +22 -0
  9. data/examples/README.md +36 -1
  10. data/examples/custom_contexts.rb +4 -4
  11. data/examples/web_search.rb +34 -0
  12. data/exe/prescient +2 -2
  13. data/exe/prescient-mcp +7 -0
  14. data/lib/prescient/agent/audit_log.rb +37 -0
  15. data/lib/prescient/agent/cli_adapter.rb +29 -0
  16. data/lib/prescient/agent/configuration.rb +57 -0
  17. data/lib/prescient/agent/context.rb +56 -0
  18. data/lib/prescient/agent/error_serializer.rb +47 -0
  19. data/lib/prescient/agent/errors.rb +25 -0
  20. data/lib/prescient/agent/parser.rb +49 -0
  21. data/lib/prescient/agent/prompt_builder.rb +31 -0
  22. data/lib/prescient/agent/result.rb +36 -0
  23. data/lib/prescient/agent/runtime.rb +175 -0
  24. data/lib/prescient/agent/schema_validator.rb +215 -0
  25. data/lib/prescient/agent/tool_registry.rb +89 -0
  26. data/lib/prescient/agent.rb +22 -0
  27. data/lib/prescient/api.rb +346 -231
  28. data/lib/prescient/base.rb +370 -372
  29. data/lib/prescient/cli.rb +591 -402
  30. data/lib/prescient/client.rb +31 -4
  31. data/lib/prescient/configuration_loader.rb +511 -336
  32. data/lib/prescient/document_source.rb +114 -0
  33. data/lib/prescient/errors.rb +13 -3
  34. data/lib/prescient/mcp/authentication.rb +39 -0
  35. data/lib/prescient/mcp/configuration.rb +38 -0
  36. data/lib/prescient/mcp/rack.rb +243 -0
  37. data/lib/prescient/mcp/server.rb +202 -0
  38. data/lib/prescient/mcp/stdio.rb +42 -0
  39. data/lib/prescient/mcp.rb +8 -0
  40. data/lib/prescient/pgvector.rb +193 -189
  41. data/lib/prescient/provider/anthropic.rb +129 -125
  42. data/lib/prescient/provider/deepseek.rb +122 -118
  43. data/lib/prescient/provider/gemini.rb +153 -149
  44. data/lib/prescient/provider/huggingface.rb +191 -187
  45. data/lib/prescient/provider/mistral.rb +151 -147
  46. data/lib/prescient/provider/ollama.rb +168 -165
  47. data/lib/prescient/provider/openai.rb +174 -171
  48. data/lib/prescient/provider/xai.rb +122 -118
  49. data/lib/prescient/tool/search_api.rb +130 -0
  50. data/lib/prescient/tool/searxng.rb +128 -0
  51. data/lib/prescient/tool.rb +125 -0
  52. data/lib/prescient/version.rb +1 -1
  53. data/lib/prescient.rb +129 -55
  54. data/schema/prescient.configuration.schema.json +119 -0
  55. data/searxng/settings.yml +18 -0
  56. data/sig/prescient.rbs +228 -1
  57. metadata +33 -5
@@ -96,9 +96,9 @@ Prescient.configure do |config|
96
96
  embedding_fields: %w[medical_conditions medications]
97
97
  },
98
98
  'appointment' => {
99
- fields: %w[patient_name date type notes doctor],
100
- format: 'Appointment for %{patient_name} on %{date} - %{type} with Dr. %{doctor}: %{notes}',
101
- embedding_fields: %w[type notes]
99
+ fields: %w[patient_name date appointment_type notes doctor],
100
+ format: 'Appointment for %{patient_name} on %{date} - %{appointment_type} with Dr. %{doctor}: %{notes}',
101
+ embedding_fields: %w[appointment_type notes]
102
102
  }
103
103
  },
104
104
  prompt_templates: {
@@ -135,7 +135,7 @@ begin
135
135
  'type' => 'appointment',
136
136
  'patient_name' => 'Patient A',
137
137
  'date' => '2024-01-20',
138
- 'type' => 'Follow-up',
138
+ 'appointment_type' => 'Follow-up',
139
139
  'notes' => 'Blood sugar levels improving with current treatment',
140
140
  'doctor' => 'Johnson'
141
141
  }
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../lib/prescient'
5
+
6
+ generate = ARGV.delete('--generate')
7
+ if ARGV.include?('--help')
8
+ puts 'Usage: ruby examples/web_search.rb [--generate] [QUERY]'
9
+ puts ' --generate Feed normalized search results to the configured AI provider'
10
+ puts ' PRESCIENT_PROVIDER Provider used with --generate (default: configured provider)'
11
+ exit
12
+ end
13
+
14
+ query = ARGV.empty? ? 'Ruby HTTP clients' : ARGV.join(' ')
15
+
16
+ Prescient.configure do |config|
17
+ config.add_tool(
18
+ :web_search,
19
+ Prescient::Tool::SearXNG,
20
+ url: ENV.fetch('SEARXNG_URL', 'http://localhost:8080'),
21
+ )
22
+ end
23
+
24
+ result = if generate
25
+ Prescient.search_and_generate(
26
+ query,
27
+ provider: ENV['PRESCIENT_PROVIDER']&.to_sym,
28
+ limit: 20,
29
+ )
30
+ else
31
+ Prescient.tool(:web_search).search(query, limit: 20)
32
+ end
33
+
34
+ puts JSON.pretty_generate(result)
data/exe/prescient CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require 'prescient'
5
- require 'prescient/cli'
4
+ require "prescient"
5
+ require "prescient/cli"
6
6
 
7
7
  exit Prescient::CLI.run(ARGV)
data/exe/prescient-mcp ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "prescient"
5
+ require "prescient/mcp"
6
+
7
+ Prescient::MCP::Stdio.new.run
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "time"
6
+
7
+ # rubocop:disable Style/ClassAndModuleChildren
8
+ module Prescient::Agent
9
+ # Persists safe Agent telemetry events as newline-delimited JSON.
10
+ class AuditLog
11
+ # Telemetry keys permitted in durable audit records.
12
+ # @return [Array<Symbol>] Safe event fields
13
+ FIELDS = %i[event loop loops_run actions success phase error].freeze
14
+
15
+ # @param path [String, nil] File path opened in append mode
16
+ # @param io [#write, nil] Writable stream owned by the caller
17
+ def initialize(path: nil, io: nil)
18
+ raise ArgumentError, "provide path or io, not both" if path && io
19
+ raise ArgumentError, "path or io is required" unless path || io
20
+
21
+ @io = io || File.open(path, "a")
22
+ @lock = Monitor.new
23
+ end
24
+
25
+ # Persist one allowlisted telemetry event.
26
+ # @param event [Hash] Agent event metadata
27
+ # @return [void]
28
+ def call(event)
29
+ record = event.slice(*FIELDS).merge(timestamp: Time.now.utc.iso8601)
30
+ @lock.synchronize do
31
+ @io.write("#{JSON.generate(record)}\n")
32
+ @io.flush if @io.respond_to?(:flush)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ # rubocop:enable Style/ClassAndModuleChildren
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren
4
+ module Prescient::Agent
5
+ # Adapts the bounded agent runtime to CLI streams and exit statuses.
6
+ class CLIAdapter
7
+ def initialize(output: $stdout, errors: $stderr)
8
+ @output = output
9
+ @errors = errors
10
+ end
11
+
12
+ # Run an agent task and render a text or JSON result.
13
+ # @return [Integer] Process exit status
14
+ def run(task:, client: nil, provider: nil, tool_names: [], max_loops: Configuration::DEFAULT_MAX_LOOPS,
15
+ format: "text", provider_options: {}, generation_options: {}, telemetry: nil)
16
+ runtime = Runtime.new(
17
+ client:, provider:, tool_names:, configuration: Configuration.new(max_loops:, telemetry:),
18
+ provider_options:, generation_options:
19
+ )
20
+ result = runtime.run(task)
21
+ format == "json" ? @output.puts(JSON.generate(result.to_h)) : @output.puts(result.response)
22
+ 0
23
+ rescue Prescient::Error, ArgumentError => e
24
+ @errors.puts "prescient: #{e.message}"
25
+ 1
26
+ end
27
+ end
28
+ end
29
+ # rubocop:enable Style/ClassAndModuleChildren
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren
4
+ module Prescient::Agent
5
+ # Runtime policies and safety limits for one agent execution.
6
+ class Configuration
7
+ # @return [Integer] Default maximum loop count
8
+ DEFAULT_MAX_LOOPS = 5
9
+ # @return [Integer] Default context size limit
10
+ DEFAULT_MAX_CONTEXT_BYTES = 64_000
11
+ # @return [Integer] Default action size limit
12
+ DEFAULT_MAX_ACTION_BYTES = 8_192
13
+ # @return [Integer] Default observation size limit
14
+ DEFAULT_MAX_OBSERVATION_BYTES = 16_384
15
+ # @return [Integer] Default task size limit
16
+ DEFAULT_MAX_TASK_BYTES = 8_192
17
+ # @return [Integer] Default generated response size limit
18
+ DEFAULT_MAX_RESPONSE_BYTES = 32_768
19
+
20
+ attr_reader :max_loops, :max_context_bytes, :max_action_bytes, :max_observation_bytes,
21
+ :max_task_bytes, :max_response_bytes, :authorization, :telemetry
22
+
23
+ # @param authorization [#call, nil] Host policy receiving tool, arguments,
24
+ # and request-scoped context. Only exactly true permits a tool call.
25
+ # @param telemetry [#call, nil] Bounded event sink for execution metadata.
26
+ def initialize(max_loops: DEFAULT_MAX_LOOPS, max_context_bytes: DEFAULT_MAX_CONTEXT_BYTES,
27
+ max_action_bytes: DEFAULT_MAX_ACTION_BYTES,
28
+ max_observation_bytes: DEFAULT_MAX_OBSERVATION_BYTES,
29
+ max_task_bytes: DEFAULT_MAX_TASK_BYTES,
30
+ max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
31
+ authorization: nil, telemetry: nil)
32
+ @max_loops = positive_integer(max_loops, "max_loops")
33
+ @max_context_bytes = positive_integer(max_context_bytes, "max_context_bytes")
34
+ @max_action_bytes = positive_integer(max_action_bytes, "max_action_bytes")
35
+ @max_observation_bytes = positive_integer(max_observation_bytes, "max_observation_bytes")
36
+ @max_task_bytes = positive_integer(max_task_bytes, "max_task_bytes")
37
+ @max_response_bytes = positive_integer(max_response_bytes, "max_response_bytes")
38
+ @authorization = callable_or_nil(authorization, "authorization")
39
+ @telemetry = callable_or_nil(telemetry, "telemetry")
40
+ end
41
+
42
+ private
43
+
44
+ def positive_integer(value, name)
45
+ return value if value.is_a?(Integer) && value.positive?
46
+
47
+ raise Prescient::Agent::ConfigurationError, "#{name} must be a positive integer"
48
+ end
49
+
50
+ def callable_or_nil(value, name)
51
+ return value if value.nil? || value.respond_to?(:call)
52
+
53
+ raise Prescient::Agent::ConfigurationError, "#{name} must respond to call"
54
+ end
55
+ end
56
+ end
57
+ # rubocop:enable Style/ClassAndModuleChildren
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren
4
+ module Prescient::Agent
5
+ # Per-run, bounded conversation history rendered as provider context.
6
+ class Context
7
+ attr_reader :messages
8
+
9
+ def initialize(system_prompt:, task:, max_bytes: Configuration::DEFAULT_MAX_CONTEXT_BYTES)
10
+ @max_bytes = max_bytes
11
+ @messages = [
12
+ { role: "system", content: system_prompt },
13
+ { role: "user", content: task }
14
+ ]
15
+ enforce_limit!
16
+ end
17
+
18
+ # Append a bounded turn to the run history.
19
+ # @param role [String] Message role
20
+ # @param content [String] Message content
21
+ # @return [void]
22
+ def append(role:, content:)
23
+ @messages << { role: role, content: content.to_s }
24
+ enforce_limit!
25
+ end
26
+
27
+ # Return a copy of the provider-compatible history.
28
+ # @return [Array<Hash>]
29
+ def to_a
30
+ @messages.map(&:dup)
31
+ end
32
+
33
+ private
34
+
35
+ def enforce_limit!
36
+ return if serialized_bytes <= @max_bytes
37
+
38
+ compact!
39
+ return if serialized_bytes <= @max_bytes
40
+
41
+ raise Prescient::Agent::ConfigurationError, "agent context exceeds configured size limit"
42
+ end
43
+
44
+ def compact!
45
+ initial_messages = @messages.first(2)
46
+ recent_messages = @messages.drop(2).last(2)
47
+ omitted_message = { role: "system", content: "[Earlier agent context omitted due to size limit.]" }
48
+ @messages = initial_messages + [omitted_message] + recent_messages
49
+ end
50
+
51
+ def serialized_bytes
52
+ JSON.generate(@messages).bytesize
53
+ end
54
+ end
55
+ end
56
+ # rubocop:enable Style/ClassAndModuleChildren
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren
4
+ module Prescient::Agent
5
+ # Converts provider and tool failures into bounded, safe agent data.
6
+ class ErrorSerializer
7
+ # @return [Array<Array<Class, String>>] Provider/tool error categories
8
+ CATEGORY_MAP = [
9
+ [Prescient::AuthenticationError, "authentication_failed"],
10
+ [Prescient::ConnectionError, "provider_unavailable"],
11
+ [Prescient::RateLimitError, "rate_limited"],
12
+ [Prescient::ModelNotAvailableError, "model_unavailable"],
13
+ [Prescient::InvalidResponseError, "invalid_provider_response"],
14
+ [Prescient::ProviderError, "provider_failure"],
15
+ [Prescient::ToolConfigurationError, "tool_configuration_error"],
16
+ [Prescient::ToolConnectionError, "tool_unavailable"],
17
+ [Prescient::ToolInvalidResponseError, "invalid_tool_response"],
18
+ [Prescient::ToolError, "tool_failure"]
19
+ ].freeze
20
+
21
+ class << self
22
+ # @param error [Exception] Failure to serialize
23
+ # @return [Hash] Safe error envelope
24
+ def serialize(error)
25
+ {
26
+ error: {
27
+ category: category_for(error),
28
+ message: safe_message(error)
29
+ }
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ def category_for(error)
36
+ CATEGORY_MAP.find { |error_class, _category| error.is_a?(error_class) }&.last || "internal_failure"
37
+ end
38
+
39
+ def safe_message(error)
40
+ return "The requested operation failed." unless error.is_a?(Prescient::Agent::Error)
41
+
42
+ error.message.to_s.byteslice(0, 512)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ # rubocop:enable Style/ClassAndModuleChildren
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prescient
4
+ module Agent
5
+ # Base error for agent-specific failures.
6
+ class Error < Prescient::Error
7
+ end
8
+
9
+ # Raised for invalid agent configuration.
10
+ class ConfigurationError < Error
11
+ end
12
+
13
+ # Raised when a provider response contains an invalid action.
14
+ class MalformedActionError < Error
15
+ end
16
+
17
+ # Raised when a model requests an unavailable tool.
18
+ class UnauthorizedToolError < Error
19
+ end
20
+
21
+ # Raised when execution reaches its configured loop limit.
22
+ class MaxLoopsExceededError < Error
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren
4
+ module Prescient::Agent
5
+ # Extracts and validates one tool action from provider text.
6
+ class Parser
7
+ # @return [Regexp] Fenced JSON action matcher
8
+ ACTION_PATTERN = /```json\s*(\{.*?\})\s*```/m
9
+
10
+ # Parse one optional action from provider output.
11
+ # @param text [String] Provider response text
12
+ # @param max_bytes [Integer] Maximum action size
13
+ # @return [Hash, nil] Parsed action or nil for a final response
14
+ def self.parse(text, max_bytes: Configuration::DEFAULT_MAX_ACTION_BYTES)
15
+ new(max_bytes:).parse(text)
16
+ end
17
+
18
+ def initialize(max_bytes: Configuration::DEFAULT_MAX_ACTION_BYTES)
19
+ @max_bytes = max_bytes
20
+ end
21
+
22
+ # Parse one optional action from provider output.
23
+ # @param text [String] Provider response text
24
+ # @return [Hash, nil] Parsed action or nil for a final response
25
+ def parse(text)
26
+ match = text.to_s.match(ACTION_PATTERN)
27
+ return nil unless match
28
+ raise MalformedActionError, "agent action exceeds configured size limit" if match[1].bytesize > @max_bytes
29
+
30
+ payload = JSON.parse(match[1])
31
+ validate_payload(payload)
32
+ { name: payload.fetch("action").to_sym, arguments: payload.fetch("args") }
33
+ rescue JSON::ParserError => e
34
+ raise MalformedActionError, "agent action contains invalid JSON: #{e.message}"
35
+ end
36
+
37
+ private
38
+
39
+ def validate_payload(payload)
40
+ unless payload.is_a?(Hash) && payload.keys.sort == %w[action args]
41
+ raise MalformedActionError, "agent action must contain only action and args"
42
+ end
43
+ return if payload["action"].is_a?(String) && !payload["action"].empty? && payload["args"].is_a?(Hash)
44
+
45
+ raise MalformedActionError, "agent action must define a name and object args"
46
+ end
47
+ end
48
+ end
49
+ # rubocop:enable Style/ClassAndModuleChildren
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren
4
+ module Prescient::Agent
5
+ # Builds deterministic instructions for the single-action agent protocol.
6
+ class PromptBuilder
7
+ # Build the system instruction for one agent run.
8
+ # @param system_instruction [String] Application-specific instruction
9
+ # @param tools [Array<ToolRegistry::Tool>] Allowed tools
10
+ # @return [String] Deterministic orchestration prompt
11
+ def self.build(system_instruction:, tools:)
12
+ tool_text = tools.empty? ? "None" : tools.map { |tool| tool_instruction(tool) }.join("\n")
13
+ <<~PROMPT
14
+ #{system_instruction}
15
+
16
+ You may either answer the task directly or request exactly one tool.
17
+ For a tool request, return one JSON object in a fenced json block:
18
+ {"action":"tool_name","args":{"key":"value"}}
19
+ Available tools:
20
+ #{tool_text}
21
+ Never request an unavailable tool. Do not return more than one action.
22
+ PROMPT
23
+ end
24
+
25
+ def self.tool_instruction(tool)
26
+ "- #{tool.name}: #{tool.description} (arguments: #{JSON.generate(tool.schema)})"
27
+ end
28
+ private_class_method :tool_instruction
29
+ end
30
+ end
31
+ # rubocop:enable Style/ClassAndModuleChildren
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren
4
+ module Prescient::Agent
5
+ # Immutable summary of one agent run.
6
+ class Result
7
+ attr_reader :response, :provider, :model, :loops_run, :metadata
8
+
9
+ def initialize(response:, provider:, model:, loops_run:, success: true, metadata: {})
10
+ @response = response
11
+ @provider = provider
12
+ @model = model
13
+ @loops_run = loops_run
14
+ @success = success
15
+ @metadata = metadata
16
+ end
17
+
18
+ def success?
19
+ @success
20
+ end
21
+
22
+ # Serialize the result into a safe structured response.
23
+ # @return [Hash] Result data
24
+ def to_h
25
+ {
26
+ response: @response,
27
+ provider: @provider,
28
+ model: @model,
29
+ loops_run: @loops_run,
30
+ success: success?,
31
+ metadata: @metadata
32
+ }
33
+ end
34
+ end
35
+ end
36
+ # rubocop:enable Style/ClassAndModuleChildren
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren
4
+ module Prescient::Agent
5
+ # Runs a bounded single-agent tool-calling loop through Prescient::Client.
6
+ class Runtime
7
+ def initialize(provider: nil, client: nil, tools: nil, tool_names: [], configuration: Configuration.new,
8
+ system_prompt: "You are a helpful assistant.", provider_options: {}, generation_options: {},
9
+ authorization: nil, telemetry: nil, enable_fallback: true,
10
+ request_context: {}, audit_log: nil)
11
+ @configuration = configuration
12
+ @telemetry = telemetry || configuration.telemetry
13
+ @audit_log = audit_log
14
+ @authorization = authorization || configuration.authorization
15
+ @request_context = request_context
16
+ @system_prompt = system_prompt
17
+ @generation_options = generation_options
18
+
19
+ begin
20
+ @client = client || Prescient.client(provider, enable_fallback:, provider_options:)
21
+ configured_tools = tools || tool_names.to_h { |name| [name, Prescient.tool(name)] }
22
+ @registry = ToolRegistry.new(configured_tools.compact)
23
+ rescue StandardError => e
24
+ emit_failure(e, phase: :initialization, loops_run: 0, actions: [])
25
+ raise
26
+ end
27
+ end
28
+
29
+ # Execute one bounded agent task.
30
+ # @param task [String] Task instruction
31
+ # @return [Result] Completed agent result
32
+ def run(task)
33
+ actions = []
34
+ loops_run = 0
35
+ validate_task(task)
36
+ context = build_context(task)
37
+
38
+ @configuration.max_loops.times do |index|
39
+ loops_run = index + 1
40
+ emit(:iteration, loop: index + 1)
41
+ response = @client.generate_response(task, context.to_a, **@generation_options)
42
+ validate_response!(response)
43
+ unless action_appended?(context, response, actions)
44
+ emit(:completed, loops_run: index + 1, actions: actions.dup, success: true)
45
+ return result(response, index + 1, actions)
46
+ end
47
+ end
48
+
49
+ emit(:max_loops_exceeded, loops_run: @configuration.max_loops, actions: actions.dup, success: false)
50
+ raise MaxLoopsExceededError, "agent exceeded maximum loops: #{@configuration.max_loops}"
51
+ rescue StandardError => e
52
+ emit_failure(e, phase: :execution, loops_run:, actions: actions.dup)
53
+ raise
54
+ end
55
+
56
+ private
57
+
58
+ def build_context(task)
59
+ Context.new(
60
+ system_prompt: PromptBuilder.build(system_instruction: @system_prompt, tools: @registry.all),
61
+ task: task,
62
+ max_bytes: @configuration.max_context_bytes
63
+ )
64
+ end
65
+
66
+ def action_appended?(context, response, actions)
67
+ action = Parser.parse(response[:response], max_bytes: @configuration.max_action_bytes)
68
+ return false unless action
69
+
70
+ observation = invoke_tool(action)
71
+ actions << action[:name].to_s
72
+ observation_text = bounded_observation(observation)
73
+ context.append(role: "assistant", content: response[:response])
74
+ context.append(role: "user", content: "Observation: #{observation_text}")
75
+ true
76
+ end
77
+
78
+ def bounded_observation(observation)
79
+ serialized = JSON.generate(observation)
80
+ return serialized if serialized.bytesize <= @configuration.max_observation_bytes
81
+
82
+ limit = @configuration.max_observation_bytes
83
+ envelope = lambda do |value|
84
+ JSON.generate(truncated: true, value: value)
85
+ end
86
+ return limit < 4 ? "0" : "null" if limit < envelope.call("").bytesize
87
+
88
+ low = 0
89
+ high = serialized.bytesize
90
+ while low < high
91
+ midpoint = (low + high + 1) / 2
92
+ candidate = envelope.call(utf8_prefix(serialized, midpoint))
93
+ if candidate.bytesize <= limit
94
+ low = midpoint
95
+ else
96
+ high = midpoint - 1
97
+ end
98
+ end
99
+
100
+ envelope.call(utf8_prefix(serialized, low))
101
+ end
102
+
103
+ def utf8_prefix(value, max_bytes)
104
+ value.byteslice(0, max_bytes).to_s.force_encoding(Encoding::UTF_8).scrub
105
+ end
106
+
107
+ def invoke_tool(action)
108
+ authorize_tool!(action)
109
+ @registry.invoke(action[:name], action[:arguments])
110
+ rescue Prescient::Error => e
111
+ raise if e.is_a?(Prescient::Agent::Error)
112
+
113
+ ErrorSerializer.serialize(e)
114
+ end
115
+
116
+ def authorize_tool!(action)
117
+ return unless @authorization
118
+ return if @authorization.call(
119
+ tool: action[:name],
120
+ arguments: action[:arguments].dup,
121
+ context: @request_context.dup
122
+ ) == true
123
+
124
+ raise UnauthorizedToolError, "agent tool not authorized: #{action[:name]}"
125
+ end
126
+
127
+ def validate_task(task)
128
+ valid = task.is_a?(String) && !task.strip.empty? && task.bytesize <= @configuration.max_task_bytes
129
+ return if valid
130
+
131
+ raise ConfigurationError, "agent task must be a non-empty string within #{@configuration.max_task_bytes} bytes"
132
+ end
133
+
134
+ def validate_response!(response)
135
+ text = response[:response]
136
+ return if text.is_a?(String) && text.bytesize <= @configuration.max_response_bytes
137
+
138
+ raise ConfigurationError,
139
+ "agent response must be a string within #{@configuration.max_response_bytes} bytes"
140
+ end
141
+
142
+ def result(response, loops_run, actions)
143
+ Result.new(
144
+ response: response[:response],
145
+ provider: response[:provider] || @client.provider_name,
146
+ model: response[:model],
147
+ loops_run: loops_run,
148
+ metadata: { actions: actions.dup, success: true }
149
+ )
150
+ end
151
+
152
+ def emit(event, attributes)
153
+ payload = { event:, **attributes }.freeze
154
+ safely_emit(@telemetry, payload)
155
+ safely_emit(@audit_log, payload)
156
+ rescue StandardError
157
+ nil
158
+ end
159
+
160
+ def safely_emit(sink, payload)
161
+ sink&.call(payload)
162
+ rescue StandardError
163
+ nil
164
+ end
165
+
166
+ def emit_failure(error, phase:, loops_run:, actions:)
167
+ emit(
168
+ :failed,
169
+ phase:, loops_run:, actions:, success: false,
170
+ error: ErrorSerializer.serialize(error).fetch(:error)
171
+ )
172
+ end
173
+ end
174
+ end
175
+ # rubocop:enable Style/ClassAndModuleChildren