agent_session_context 1.0.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/CHANGELOG.md +43 -0
- data/LICENSE.txt +21 -0
- data/README.md +298 -0
- data/exe/agent-session-context +6 -0
- data/lib/agent/session_context/builder.rb +150 -0
- data/lib/agent/session_context/cli/options.rb +214 -0
- data/lib/agent/session_context/cli.rb +254 -0
- data/lib/agent/session_context/config.rb +206 -0
- data/lib/agent/session_context/errors.rb +15 -0
- data/lib/agent/session_context/evidence_collector.rb +227 -0
- data/lib/agent/session_context/evidence_packet.rb +271 -0
- data/lib/agent/session_context/immutable_value.rb +71 -0
- data/lib/agent/session_context/injected_context.rb +92 -0
- data/lib/agent/session_context/injected_context_collector.rb +53 -0
- data/lib/agent/session_context/item.rb +54 -0
- data/lib/agent/session_context/loop.rb +122 -0
- data/lib/agent/session_context/loop_view.rb +248 -0
- data/lib/agent/session_context/prompt.rb +40 -0
- data/lib/agent/session_context/prompt_extractor.rb +31 -0
- data/lib/agent/session_context/renderers/human_display.rb +113 -0
- data/lib/agent/session_context/renderers/json.rb +21 -0
- data/lib/agent/session_context/renderers/json_lines.rb +25 -0
- data/lib/agent/session_context/renderers/markdown.rb +130 -0
- data/lib/agent/session_context/renderers/serializer.rb +124 -0
- data/lib/agent/session_context/renderers/text.rb +128 -0
- data/lib/agent/session_context/semantic_categories.rb +89 -0
- data/lib/agent/session_context/semantic_pipeline.rb +151 -0
- data/lib/agent/session_context/semantic_schema.rb +75 -0
- data/lib/agent/session_context/session_resolver.rb +147 -0
- data/lib/agent/session_context/snapshot.rb +128 -0
- data/lib/agent/session_context/source_ref.rb +46 -0
- data/lib/agent/session_context/subprocess_runner.rb +362 -0
- data/lib/agent/session_context/summarizers/claude.rb +126 -0
- data/lib/agent/session_context/summarizers/codex.rb +132 -0
- data/lib/agent/session_context/summarizers/command_execution_policy.rb +134 -0
- data/lib/agent/session_context/summarizers.rb +35 -0
- data/lib/agent/session_context/summary_parser.rb +219 -0
- data/lib/agent/session_context/tool_call.rb +21 -0
- data/lib/agent/session_context/transcript.rb +236 -0
- data/lib/agent/session_context/version.rb +7 -0
- data/lib/agent/session_context.rb +60 -0
- metadata +115 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module SessionContext
|
|
7
|
+
module Summarizers
|
|
8
|
+
class Claude
|
|
9
|
+
include CommandExecutionPolicy
|
|
10
|
+
|
|
11
|
+
MAX_OUTPUT_BYTES = 1_048_576
|
|
12
|
+
PROVIDER_ENV_KEYS = %w[ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN CLAUDE_CONFIG_DIR].freeze
|
|
13
|
+
private_constant :PROVIDER_ENV_KEYS
|
|
14
|
+
|
|
15
|
+
def initialize(runner: nil, timeout_seconds: Config::DEFAULT_TIMEOUT_SECONDS)
|
|
16
|
+
initialize_command_execution_policy(
|
|
17
|
+
runner:,
|
|
18
|
+
timeout_seconds:,
|
|
19
|
+
max_output_bytes: MAX_OUTPUT_BYTES,
|
|
20
|
+
provider_env_keys: PROVIDER_ENV_KEYS
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def name
|
|
25
|
+
:claude
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(prompt:, schema:)
|
|
29
|
+
stdout, stderr, status = run_command!(prompt:, argv: command_argv(schema))
|
|
30
|
+
fail_command!(stdout:, stderr:, status:) unless status.success?
|
|
31
|
+
if stdout.strip.empty?
|
|
32
|
+
raise SummarizerFailed, failure_message(
|
|
33
|
+
"#{name} summarizer produced empty stdout",
|
|
34
|
+
stdout:,
|
|
35
|
+
stderr:
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
envelope = parse_envelope!(stdout, stderr:)
|
|
40
|
+
field_name, value = extract_structured_value!(envelope)
|
|
41
|
+
|
|
42
|
+
normalize_value!(value, field_name:, stdout:, stderr:)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def command_argv(schema)
|
|
48
|
+
[
|
|
49
|
+
"claude",
|
|
50
|
+
"--print",
|
|
51
|
+
"--safe-mode",
|
|
52
|
+
"--tools",
|
|
53
|
+
"",
|
|
54
|
+
"--no-session-persistence",
|
|
55
|
+
"--output-format",
|
|
56
|
+
"json",
|
|
57
|
+
"--json-schema",
|
|
58
|
+
JSON.generate(schema)
|
|
59
|
+
]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_envelope!(stdout, stderr:)
|
|
63
|
+
envelope = JSON.parse(stdout)
|
|
64
|
+
unless envelope.is_a?(Hash)
|
|
65
|
+
raise SummarizerFailed, failure_message(
|
|
66
|
+
"#{name} summarizer produced a non-object JSON envelope",
|
|
67
|
+
stdout:,
|
|
68
|
+
stderr:
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
envelope
|
|
73
|
+
rescue JSON::ParserError, EncodingError, ArgumentError
|
|
74
|
+
raise SummarizerFailed, failure_message(
|
|
75
|
+
"#{name} summarizer produced invalid JSON envelope",
|
|
76
|
+
stdout:,
|
|
77
|
+
stderr:
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def extract_structured_value!(envelope)
|
|
82
|
+
return ["structured_output", envelope["structured_output"]] if envelope.key?("structured_output")
|
|
83
|
+
return ["result", envelope["result"]] if envelope.key?("result")
|
|
84
|
+
|
|
85
|
+
raise SummarizerFailed, "#{name} summarizer JSON envelope did not include structured_output or result"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def normalize_value!(value, field_name:, stdout:, stderr:)
|
|
89
|
+
case value
|
|
90
|
+
when String
|
|
91
|
+
normalize_json_string!(value, stdout:, stderr:, label: field_name)
|
|
92
|
+
else
|
|
93
|
+
JSON.generate(JSON.parse(JSON.generate(value)))
|
|
94
|
+
end
|
|
95
|
+
rescue JSON::GeneratorError, TypeError
|
|
96
|
+
raise SummarizerFailed, failure_message(
|
|
97
|
+
"#{name} summarizer produced a non-JSON #{field_name} value",
|
|
98
|
+
stdout:,
|
|
99
|
+
stderr:,
|
|
100
|
+
output: value.inspect
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalize_json_string!(value, stdout:, stderr:, label:)
|
|
105
|
+
JSON.generate(JSON.parse(value))
|
|
106
|
+
rescue JSON::ParserError, EncodingError, ArgumentError
|
|
107
|
+
raise SummarizerFailed, failure_message(
|
|
108
|
+
"#{name} summarizer produced invalid JSON in #{label}",
|
|
109
|
+
stdout:,
|
|
110
|
+
stderr:,
|
|
111
|
+
output: value
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def fail_command!(stdout:, stderr:, status:)
|
|
116
|
+
raise SummarizerFailed, failure_message(
|
|
117
|
+
"#{name} summarizer command failed",
|
|
118
|
+
stdout:,
|
|
119
|
+
stderr:,
|
|
120
|
+
status:
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Agent
|
|
7
|
+
module SessionContext
|
|
8
|
+
module Summarizers
|
|
9
|
+
class Codex
|
|
10
|
+
include CommandExecutionPolicy
|
|
11
|
+
|
|
12
|
+
MAX_OUTPUT_BYTES = 1_048_576
|
|
13
|
+
PROVIDER_ENV_KEYS = %w[CODEX_HOME OPENAI_API_KEY].freeze
|
|
14
|
+
WHITESPACE_BYTES = [9, 10, 11, 12, 13, 32].freeze
|
|
15
|
+
private_constant :PROVIDER_ENV_KEYS, :WHITESPACE_BYTES
|
|
16
|
+
|
|
17
|
+
def initialize(runner: nil, timeout_seconds: Config::DEFAULT_TIMEOUT_SECONDS)
|
|
18
|
+
initialize_command_execution_policy(
|
|
19
|
+
runner:,
|
|
20
|
+
timeout_seconds:,
|
|
21
|
+
max_output_bytes: MAX_OUTPUT_BYTES,
|
|
22
|
+
provider_env_keys: PROVIDER_ENV_KEYS
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def name
|
|
27
|
+
:codex
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(prompt:, schema:)
|
|
31
|
+
Tempfile.create(["agent-context-codex-schema", ".json"]) do |schema_file|
|
|
32
|
+
schema_file.write(JSON.generate(schema))
|
|
33
|
+
schema_file.flush
|
|
34
|
+
|
|
35
|
+
Tempfile.create(["agent-context-codex-output", ".json"]) do |output_file|
|
|
36
|
+
stdout, stderr, status = run_command!(
|
|
37
|
+
prompt:,
|
|
38
|
+
argv: command_argv(schema_path: schema_file.path, output_path: output_file.path)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
fail_command!(stdout:, stderr:, status:) unless status.success?
|
|
42
|
+
|
|
43
|
+
payload = read_last_message!(output_file.path, stdout:, stderr:)
|
|
44
|
+
normalize_json!(payload, source: "last message", stdout:, stderr:)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def command_argv(schema_path:, output_path:)
|
|
52
|
+
[
|
|
53
|
+
"codex",
|
|
54
|
+
"exec",
|
|
55
|
+
"--ephemeral",
|
|
56
|
+
"--sandbox",
|
|
57
|
+
"read-only",
|
|
58
|
+
"--ignore-user-config",
|
|
59
|
+
"--ignore-rules",
|
|
60
|
+
"--skip-git-repo-check",
|
|
61
|
+
"--output-schema",
|
|
62
|
+
schema_path,
|
|
63
|
+
"--output-last-message",
|
|
64
|
+
output_path,
|
|
65
|
+
"-"
|
|
66
|
+
]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def read_last_message!(path, stdout:, stderr:)
|
|
70
|
+
payload = bounded_file_read(path, "last message", stdout:, stderr:)
|
|
71
|
+
rescue Errno::ENOENT
|
|
72
|
+
raise SummarizerFailed, failure_message(
|
|
73
|
+
"#{name} summarizer did not produce a last message file; output was missing",
|
|
74
|
+
stdout:,
|
|
75
|
+
stderr:
|
|
76
|
+
)
|
|
77
|
+
else
|
|
78
|
+
if blank_bytes?(payload)
|
|
79
|
+
raise SummarizerFailed, failure_message(
|
|
80
|
+
"#{name} summarizer produced an empty last message file",
|
|
81
|
+
stdout:,
|
|
82
|
+
stderr:,
|
|
83
|
+
output: payload
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
payload
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_json!(payload, source:, stdout:, stderr:)
|
|
91
|
+
JSON.generate(JSON.parse(payload))
|
|
92
|
+
rescue JSON::ParserError, EncodingError, ArgumentError
|
|
93
|
+
raise SummarizerFailed, failure_message(
|
|
94
|
+
"#{name} summarizer produced invalid JSON in #{source}",
|
|
95
|
+
stdout:,
|
|
96
|
+
stderr:,
|
|
97
|
+
output: payload
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fail_command!(stdout:, stderr:, status:)
|
|
102
|
+
raise SummarizerFailed, failure_message(
|
|
103
|
+
"#{name} summarizer command failed",
|
|
104
|
+
stdout:,
|
|
105
|
+
stderr:,
|
|
106
|
+
status:
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def bounded_file_read(path, label, stdout:, stderr:)
|
|
111
|
+
File.open(path, "rb") do |file|
|
|
112
|
+
payload = file.read(MAX_OUTPUT_BYTES + 1) || "".b
|
|
113
|
+
if payload.bytesize > MAX_OUTPUT_BYTES
|
|
114
|
+
raise SummarizerFailed, failure_message(
|
|
115
|
+
"#{name} summarizer #{label} exceeded #{MAX_OUTPUT_BYTES} bytes",
|
|
116
|
+
stdout:,
|
|
117
|
+
stderr:,
|
|
118
|
+
output: payload
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
payload
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def blank_bytes?(value)
|
|
127
|
+
value.empty? || value.bytes.all? { |byte| WHITESPACE_BYTES.include?(byte) }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module SessionContext
|
|
5
|
+
module Summarizers
|
|
6
|
+
module CommandExecutionPolicy
|
|
7
|
+
COMMON_ENV_KEYS = %w[
|
|
8
|
+
ALL_PROXY
|
|
9
|
+
HOME
|
|
10
|
+
HTTP_PROXY
|
|
11
|
+
HTTPS_PROXY
|
|
12
|
+
LANG
|
|
13
|
+
NO_PROXY
|
|
14
|
+
PATH
|
|
15
|
+
SSL_CERT_DIR
|
|
16
|
+
SSL_CERT_FILE
|
|
17
|
+
all_proxy
|
|
18
|
+
http_proxy
|
|
19
|
+
https_proxy
|
|
20
|
+
no_proxy
|
|
21
|
+
].freeze
|
|
22
|
+
private_constant :COMMON_ENV_KEYS
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def initialize_command_execution_policy(runner:, timeout_seconds:, max_output_bytes:, provider_env_keys:)
|
|
27
|
+
@timeout_seconds = timeout_seconds
|
|
28
|
+
@max_output_bytes = max_output_bytes
|
|
29
|
+
@provider_env_keys = provider_env_keys.dup.freeze
|
|
30
|
+
@runner = runner || SubprocessRunner.new(timeout_seconds:, max_output_bytes:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run_command!(prompt:, argv:)
|
|
34
|
+
response = @runner.call(env: child_env, argv:, stdin_data: prompt)
|
|
35
|
+
validate_runner_response!(response)
|
|
36
|
+
rescue Errno::ENOENT => e
|
|
37
|
+
raise SummarizerUnavailable, "#{name} summarizer is unavailable: #{e.message}"
|
|
38
|
+
rescue SubprocessRunner::TimeoutError
|
|
39
|
+
raise SummarizerFailed, "#{name} summarizer exceeded its #{@timeout_seconds}-second timeout"
|
|
40
|
+
rescue SubprocessRunner::OutputLimitError => e
|
|
41
|
+
raise SummarizerFailed,
|
|
42
|
+
"#{name} summarizer #{translated_stream_label(e.stream)} exceeded #{@max_output_bytes} bytes"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_runner_response!(response)
|
|
46
|
+
unless response.is_a?(Array) && response.length == 3
|
|
47
|
+
raise SummarizerFailed, "#{name} summarizer runner must return [stdout, stderr, status]"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
stdout, stderr, status = response
|
|
51
|
+
|
|
52
|
+
unless status.respond_to?(:success?)
|
|
53
|
+
raise SummarizerFailed, "#{name} summarizer runner must return a status with #success?"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
unless stdout.nil? || stdout.is_a?(String)
|
|
57
|
+
raise SummarizerFailed, "#{name} summarizer runner must return stdout as a String"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
unless stderr.nil? || stderr.is_a?(String)
|
|
61
|
+
raise SummarizerFailed, "#{name} summarizer runner must return stderr as a String"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
stdout = stdout.to_s
|
|
65
|
+
stderr = stderr.to_s
|
|
66
|
+
enforce_output_bounds!("stdout", stdout, status:)
|
|
67
|
+
enforce_output_bounds!("stderr", stderr, status:)
|
|
68
|
+
|
|
69
|
+
[stdout, stderr, status]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def failure_message(summary, stdout: nil, stderr: nil, output: nil, status: nil)
|
|
73
|
+
fragments = [summary]
|
|
74
|
+
append_stream_metadata(fragments, "stdout", stdout)
|
|
75
|
+
append_stream_metadata(fragments, "stderr", stderr)
|
|
76
|
+
append_stream_metadata(fragments, "output", output)
|
|
77
|
+
append_status_metadata(fragments, status)
|
|
78
|
+
fragments.join(". ")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def child_env
|
|
82
|
+
ENV.each_with_object({}) do |(key, value), filtered|
|
|
83
|
+
filtered[key] = value if allowed_env_key?(key)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def allowed_env_key?(key)
|
|
88
|
+
COMMON_ENV_KEYS.include?(key) || @provider_env_keys.include?(key) || key.start_with?("LC_")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def enforce_output_bounds!(label, value, status:)
|
|
92
|
+
return if value.bytesize <= @max_output_bytes
|
|
93
|
+
|
|
94
|
+
raise SummarizerFailed, failure_message(
|
|
95
|
+
"#{name} summarizer #{label} exceeded #{@max_output_bytes} bytes",
|
|
96
|
+
status:,
|
|
97
|
+
stdout: label == "stdout" ? value : nil,
|
|
98
|
+
stderr: label == "stderr" ? value : nil
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def append_stream_metadata(fragments, label, value)
|
|
103
|
+
state =
|
|
104
|
+
if value.nil?
|
|
105
|
+
"absent"
|
|
106
|
+
elsif value.empty?
|
|
107
|
+
"empty"
|
|
108
|
+
else
|
|
109
|
+
"present"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
bytesize = value.nil? ? 0 : value.bytesize
|
|
113
|
+
fragments << "#{label}=#{state}(#{bytesize} bytes)"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def append_status_metadata(fragments, status)
|
|
117
|
+
return unless status.respond_to?(:exitstatus)
|
|
118
|
+
|
|
119
|
+
exitstatus = status.exitstatus
|
|
120
|
+
return if exitstatus.nil?
|
|
121
|
+
|
|
122
|
+
fragments << "exit_status=#{exitstatus}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def translated_stream_label(stream)
|
|
126
|
+
return "stdout" if stream.equal?(:stdout)
|
|
127
|
+
return "stderr" if stream.equal?(:stderr)
|
|
128
|
+
|
|
129
|
+
"output"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module SessionContext
|
|
5
|
+
module Summarizers
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def for(name, timeout_seconds: Config::DEFAULT_TIMEOUT_SECONDS)
|
|
9
|
+
case normalize_name(name)
|
|
10
|
+
when :codex then Codex.new(timeout_seconds:)
|
|
11
|
+
when :claude then Claude.new(timeout_seconds:)
|
|
12
|
+
else
|
|
13
|
+
raise unsupported_summarizer(name)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def normalize_name(name)
|
|
18
|
+
return name if name.is_a?(Symbol)
|
|
19
|
+
raise unsupported_summarizer(name) unless name.respond_to?(:to_sym)
|
|
20
|
+
|
|
21
|
+
normalized = name.to_sym
|
|
22
|
+
raise unsupported_summarizer(name) unless normalized.is_a?(Symbol)
|
|
23
|
+
|
|
24
|
+
normalized
|
|
25
|
+
rescue NoMethodError, TypeError, ArgumentError
|
|
26
|
+
raise unsupported_summarizer(name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def unsupported_summarizer(name)
|
|
30
|
+
UnsupportedAgent.new("unsupported summarizer #{name.inspect}; use claude or codex")
|
|
31
|
+
end
|
|
32
|
+
private_class_method :normalize_name, :unsupported_summarizer
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module SessionContext
|
|
5
|
+
class SummaryParser
|
|
6
|
+
Result = Data.define(:items, :warnings)
|
|
7
|
+
|
|
8
|
+
ALLOWED_EVIDENCE = %w[explicit inferred].freeze
|
|
9
|
+
TEXT_KEYS = %w[text evidence source_refs].freeze
|
|
10
|
+
TERM_KEYS = %w[term definition evidence source_refs].freeze
|
|
11
|
+
ITEM_KEYS = {
|
|
12
|
+
text: TEXT_KEYS,
|
|
13
|
+
term: TERM_KEYS
|
|
14
|
+
}.freeze
|
|
15
|
+
REQUIRED_CATEGORIES = SemanticCategories.external_keys
|
|
16
|
+
|
|
17
|
+
def call(json, allowed_refs:)
|
|
18
|
+
payload = parse_payload(json)
|
|
19
|
+
allowed_ref_index = build_allowed_ref_index(allowed_refs)
|
|
20
|
+
validate_known_categories!(payload)
|
|
21
|
+
warnings = []
|
|
22
|
+
merged_items = {}
|
|
23
|
+
ordered_items = []
|
|
24
|
+
|
|
25
|
+
payload.each do |category, value|
|
|
26
|
+
definition = SemanticCategories.lookup(category)
|
|
27
|
+
unless definition
|
|
28
|
+
warnings << "Dropped unknown category #{category.inspect}"
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
value.each_with_index do |raw_item, item_index|
|
|
33
|
+
item = parse_item(definition, raw_item, item_index, allowed_ref_index, warnings)
|
|
34
|
+
next unless item
|
|
35
|
+
|
|
36
|
+
key = [item.kind, item.label, item.detail]
|
|
37
|
+
existing = merged_items[key]
|
|
38
|
+
|
|
39
|
+
if existing
|
|
40
|
+
merged_items[key] = merge_item_refs(existing, item)
|
|
41
|
+
else
|
|
42
|
+
merged_items[key] = item
|
|
43
|
+
ordered_items << key
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Result.new(
|
|
49
|
+
items: ordered_items.map { |key| merged_items.fetch(key) }.freeze,
|
|
50
|
+
warnings: warnings.map { |warning| String.new(warning).freeze }.freeze
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def parse_payload(json)
|
|
57
|
+
payload = JSON.parse(json)
|
|
58
|
+
rescue JSON::ParserError
|
|
59
|
+
raise InvalidSummary, "Invalid JSON summary. Provide a JSON top-level object with the supported categories."
|
|
60
|
+
else
|
|
61
|
+
raise InvalidSummary, "Summary must be a JSON top-level object." unless payload.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
payload
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_known_categories!(payload)
|
|
67
|
+
REQUIRED_CATEGORIES.each do |category|
|
|
68
|
+
next unless payload.key?(category)
|
|
69
|
+
|
|
70
|
+
raise InvalidSummary, "#{category} must be an array" unless payload[category].is_a?(Array)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
missing_categories = REQUIRED_CATEGORIES - payload.keys
|
|
74
|
+
return if missing_categories.empty?
|
|
75
|
+
|
|
76
|
+
raise InvalidSummary,
|
|
77
|
+
"Summary is missing required categories: #{missing_categories.join(", ")}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_allowed_ref_index(allowed_refs)
|
|
81
|
+
normalize_allowed_refs(allowed_refs).to_h do |source_ref|
|
|
82
|
+
[source_ref.to_s, source_ref]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_allowed_refs(allowed_refs)
|
|
87
|
+
refs =
|
|
88
|
+
if allowed_refs.is_a?(Array)
|
|
89
|
+
allowed_refs
|
|
90
|
+
elsif allowed_refs.respond_to?(:to_a) && !allowed_refs.is_a?(String)
|
|
91
|
+
allowed_refs.to_a
|
|
92
|
+
elsif allowed_refs.respond_to?(:each) && !allowed_refs.is_a?(String)
|
|
93
|
+
allowed_refs.each_with_object([]) { |source_ref, collected| collected << source_ref }
|
|
94
|
+
else
|
|
95
|
+
raise TypeError, "allowed_refs must be an enumerable of Agent::SessionContext::SourceRef objects"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
refs.each do |source_ref|
|
|
99
|
+
unless source_ref.is_a?(SourceRef)
|
|
100
|
+
raise TypeError,
|
|
101
|
+
"allowed_refs must contain only Agent::SessionContext::SourceRef objects"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
refs
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_item(category, raw_item, item_index, allowed_ref_index, warnings)
|
|
109
|
+
category_name = category.external_key
|
|
110
|
+
|
|
111
|
+
unless raw_item.is_a?(Hash)
|
|
112
|
+
warnings << "Dropped #{category_name}[#{item_index}] because items must be objects"
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
expected_keys = ITEM_KEYS.fetch(category.item_shape)
|
|
117
|
+
raw_keys = raw_item.keys.sort
|
|
118
|
+
|
|
119
|
+
unless raw_keys == expected_keys.sort
|
|
120
|
+
warnings << key_mismatch_warning(category_name, item_index, raw_keys, expected_keys)
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
evidence = raw_item["evidence"]
|
|
125
|
+
unless ALLOWED_EVIDENCE.include?(evidence)
|
|
126
|
+
warnings << "Dropped #{category_name}[#{item_index}] because evidence must be explicit or inferred"
|
|
127
|
+
return
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
refs = resolve_source_refs(category_name, item_index, raw_item["source_refs"], allowed_ref_index, warnings)
|
|
131
|
+
return unless refs
|
|
132
|
+
|
|
133
|
+
case category.item_shape
|
|
134
|
+
when :term
|
|
135
|
+
term = raw_item["term"]
|
|
136
|
+
definition = raw_item["definition"]
|
|
137
|
+
|
|
138
|
+
unless term.is_a?(String)
|
|
139
|
+
warnings << "Dropped #{category_name}[#{item_index}] because term must be a string"
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
unless definition.is_a?(String)
|
|
144
|
+
warnings << "Dropped #{category_name}[#{item_index}] because definition must be a string"
|
|
145
|
+
return
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
Item.new(
|
|
149
|
+
kind: category.internal_kind,
|
|
150
|
+
label: term,
|
|
151
|
+
detail: definition,
|
|
152
|
+
evidence: evidence.to_sym,
|
|
153
|
+
source_refs: refs
|
|
154
|
+
)
|
|
155
|
+
else
|
|
156
|
+
text = raw_item["text"]
|
|
157
|
+
unless text.is_a?(String)
|
|
158
|
+
warnings << "Dropped #{category_name}[#{item_index}] because text must be a string"
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
Item.new(
|
|
163
|
+
kind: category.internal_kind,
|
|
164
|
+
label: text,
|
|
165
|
+
detail: nil,
|
|
166
|
+
evidence: evidence.to_sym,
|
|
167
|
+
source_refs: refs
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def resolve_source_refs(category, item_index, raw_refs, allowed_ref_index, warnings)
|
|
173
|
+
unless raw_refs.is_a?(Array) && !raw_refs.empty? && raw_refs.all?(String)
|
|
174
|
+
warnings << "Dropped #{category}[#{item_index}] because source_refs must be a non-empty array of strings"
|
|
175
|
+
return
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
resolved = raw_refs.map { |value| allowed_ref_index[value] }
|
|
179
|
+
if resolved.any?(&:nil?)
|
|
180
|
+
warnings << "Dropped #{category}[#{item_index}] because it referenced an unknown source ref"
|
|
181
|
+
return
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
resolved.uniq.freeze
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def merge_item_refs(existing, item)
|
|
188
|
+
Item.new(
|
|
189
|
+
kind: existing.kind,
|
|
190
|
+
label: existing.label,
|
|
191
|
+
detail: existing.detail,
|
|
192
|
+
evidence: merged_evidence(existing.evidence, item.evidence),
|
|
193
|
+
source_refs: (existing.source_refs + item.source_refs).uniq,
|
|
194
|
+
attributes: existing.attributes
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def merged_evidence(left, right)
|
|
199
|
+
return :explicit if left == :explicit || right == :explicit
|
|
200
|
+
|
|
201
|
+
:inferred
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def key_mismatch_warning(category, item_index, raw_keys, expected_keys)
|
|
205
|
+
missing_keys = expected_keys - raw_keys
|
|
206
|
+
extra_keys = raw_keys - expected_keys
|
|
207
|
+
details = []
|
|
208
|
+
details << "missing #{missing_keys.join(", ")}" unless missing_keys.empty?
|
|
209
|
+
details << "extra keys #{extra_keys.join(", ")}" unless extra_keys.empty?
|
|
210
|
+
details << "additional properties are not allowed" unless extra_keys.empty?
|
|
211
|
+
detail_text = details.join("; ")
|
|
212
|
+
detail_suffix = " (#{detail_text})" unless detail_text.empty?
|
|
213
|
+
|
|
214
|
+
"Dropped #{category}[#{item_index}] because item keys must exactly match " \
|
|
215
|
+
"#{expected_keys.join(", ")}#{detail_suffix}"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module SessionContext
|
|
5
|
+
# One tool call, paired with whatever answered it. The pairing is by
|
|
6
|
+
# CALL ID, never by position — a model can have two calls in flight at
|
|
7
|
+
# once, and pairing by position would match the wrong call to the wrong
|
|
8
|
+
# result.
|
|
9
|
+
#
|
|
10
|
+
# result_bytes is nil, not 0, when nothing answered the call: an
|
|
11
|
+
# unanswered call and a call answered with an empty body are different
|
|
12
|
+
# facts, and defaulting the body to "" would report a 0-byte answer for
|
|
13
|
+
# a call that was never answered.
|
|
14
|
+
#
|
|
15
|
+
# Only sizes are carried, never bodies — transcripts hold credentials
|
|
16
|
+
# and customer data, and this gem has no redaction before milestone 0.5.
|
|
17
|
+
ToolCall = Data.define(:name, :call_id, :input_bytes, :result_bytes, :asked_in, :answered_in) do
|
|
18
|
+
def answered? = !result_bytes.nil?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|