scout-ai 1.2.3 → 2.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 +4 -4
- data/.vimproject +138 -50
- data/README.md +171 -290
- data/Rakefile +17 -1
- data/VERSION +1 -1
- data/doc/Improvements.md +325 -0
- data/doc/StartHere.md +110 -0
- data/doc/developer/Architecture.md +126 -0
- data/doc/developer/Backends.md +199 -0
- data/doc/developer/ChatLifecycle.md +183 -0
- data/doc/developer/DelegationInternals.md +295 -0
- data/doc/developer/DesignPrinciples.md +245 -0
- data/doc/developer/PromptProcessing.md +292 -0
- data/doc/developer/Provenance.md +317 -0
- data/doc/user/BuildingAgents.md +345 -0
- data/doc/user/Cookbook.md +333 -0
- data/doc/user/CoreConcepts.md +181 -0
- data/doc/user/Delegation.md +191 -0
- data/doc/user/GettingStarted.md +159 -0
- data/doc/user/ManagingContext.md +163 -0
- data/doc/user/MultiAgentWorkflows.md +256 -0
- data/doc/user/Python.md +159 -0
- data/doc/user/RunningInference.md +200 -0
- data/doc/user/ToolCalling.md +193 -0
- data/doc/user/WritingChats.md +197 -0
- data/lib/scout/llm/agent/chat.rb +61 -11
- data/lib/scout/llm/agent/delegate.rb +274 -65
- data/lib/scout/llm/agent/iterate.rb +2 -2
- data/lib/scout/llm/agent/save.rb +273 -0
- data/lib/scout/llm/agent/workflow.rb +164 -0
- data/lib/scout/llm/agent.rb +86 -61
- data/lib/scout/llm/ask.rb +62 -17
- data/lib/scout/llm/backends/anthropic.rb +9 -2
- data/lib/scout/llm/backends/bedrock.rb +15 -3
- data/lib/scout/llm/backends/default.rb +183 -99
- data/lib/scout/llm/backends/glm.rb +58 -0
- data/lib/scout/llm/backends/huggingface.rb +196 -26
- data/lib/scout/llm/backends/ollama.rb +13 -1
- data/lib/scout/llm/backends/openai.rb +0 -2
- data/lib/scout/llm/backends/openwebui.rb +20 -13
- data/lib/scout/llm/backends/relay.rb +22 -22
- data/lib/scout/llm/backends/responses.rb +1 -1
- data/lib/scout/llm/chat/agent_meta.rb +264 -0
- data/lib/scout/llm/chat/annotation.rb +39 -10
- data/lib/scout/llm/chat/parse.rb +28 -6
- data/lib/scout/llm/chat/persist.rb +25 -0
- data/lib/scout/llm/chat/process/clear.rb +41 -6
- data/lib/scout/llm/chat/process/files.rb +21 -6
- data/lib/scout/llm/chat/process/meta.rb +421 -34
- data/lib/scout/llm/chat/process/options.rb +21 -1
- data/lib/scout/llm/chat/process/tools.rb +56 -15
- data/lib/scout/llm/chat/process.rb +4 -0
- data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
- data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
- data/lib/scout/llm/chat/prompt.rb +48 -0
- data/lib/scout/llm/chat/provenance.rb +775 -0
- data/lib/scout/llm/chat/tool_calls.rb +76 -0
- data/lib/scout/llm/chat.rb +18 -2
- data/lib/scout/llm/embed.rb +11 -3
- data/lib/scout/llm/image.rb +86 -0
- data/lib/scout/llm/mcp.rb +10 -2
- data/lib/scout/llm/rag.rb +3 -3
- data/lib/scout/llm/tools/call.rb +160 -11
- data/lib/scout/llm/tools/knowledge_base.rb +1 -1
- data/lib/scout/llm/tools/workflow.rb +32 -16
- data/lib/scout/model/python/huggingface/causal.rb +23 -5
- data/lib/scout/model/python/huggingface.rb +2 -1
- data/lib/scout-ai.rb +1 -0
- data/python/README.md +197 -14
- data/python/scout_ai/huggingface/eval.py +245 -34
- data/python/tests/test_huggingface_eval.py +58 -0
- data/research/ChatAnalyst-required-changes.md +167 -0
- data/research/agent-delegation-analysis.md +810 -0
- data/research/agent-meta-provenance-integration-plan.md +622 -0
- data/research/agent-workflow-analysis.md +1120 -0
- data/research/backends-analysis.md +836 -0
- data/research/chat-core-analysis.md +946 -0
- data/research/chatanalyst-provenance/00-baseline.md +30 -0
- data/research/chatanalyst-provenance/01-repo-map.md +60 -0
- data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
- data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
- data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
- data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
- data/research/chatanalyst-provenance/07-critic-review.md +25 -0
- data/research/chatanalyst-provenance/final-report.md +45 -0
- data/research/chatanalyst-provenance/resumption.md +37 -0
- data/research/coding-philosophy-analysis.md +928 -0
- data/research/commands-analysis.md +947 -0
- data/research/multi-agent-patterns-analysis.md +853 -0
- data/research/prompt-strategies-analysis.md +630 -0
- data/research/prov-verbosity-fix-notes.md +77 -0
- data/research/provenance-analysis.md +469 -0
- data/research/provenance-navigation-design.md +640 -0
- data/research/synthesis-report.md +487 -0
- data/research/tools-system-analysis.md +779 -0
- data/scout-ai.gemspec +100 -11
- data/scout_commands/agent/ask +13 -3
- data/scout_commands/agent/kb +2 -0
- data/scout_commands/llm/ask +11 -4
- data/scout_commands/llm/md +76 -0
- data/scout_commands/llm/process_queries +48 -0
- data/scout_commands/llm/prov +602 -0
- data/scout_commands/llm/word +71 -0
- data/scout_commands/workflow/mcp +43 -0
- data/share/word/reference.docx +0 -0
- data/test/etc/AI/mock.yaml +11 -0
- data/test/fixtures/backends/anthropic.json +19 -0
- data/test/fixtures/backends/anthropic_tool_use.json +24 -0
- data/test/fixtures/backends/bedrock.json +8 -0
- data/test/fixtures/backends/bedrock_embedding.json +3 -0
- data/test/fixtures/backends/bedrock_tool_use.json +17 -0
- data/test/fixtures/backends/ollama.json +16 -0
- data/test/fixtures/backends/ollama_tool_call.json +27 -0
- data/test/fixtures/backends/openai_chat.json +21 -0
- data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
- data/test/fixtures/backends/responses.json +33 -0
- data/test/fixtures/backends/responses_tool_call.json +28 -0
- data/test/integration/README.md +32 -0
- data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
- data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
- data/test/integration/scout/llm/backends/test_relay.rb +52 -0
- data/test/integration/scout/llm/test_infrastructure.rb +74 -0
- data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
- data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
- data/test/integration/scout/model/test_base.rb +91 -0
- data/test/scout/llm/agent/test_chat.rb +8 -2
- data/test/scout/llm/agent/test_save.rb +413 -0
- data/test/scout/llm/agent/test_workflow.rb +110 -0
- data/test/scout/llm/backends/test_anthropic.rb +93 -10
- data/test/scout/llm/backends/test_bedrock.rb +118 -2
- data/test/scout/llm/backends/test_huggingface.rb +137 -42
- data/test/scout/llm/backends/test_ollama.rb +70 -20
- data/test/scout/llm/backends/test_openwebui.rb +42 -40
- data/test/scout/llm/backends/test_relay.rb +4 -2
- data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
- data/test/scout/llm/chat/process/test_meta.rb +518 -0
- data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
- data/test/scout/llm/chat/test_agent_meta.rb +357 -0
- data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
- data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
- data/test/scout/llm/chat/test_parse.rb +70 -15
- data/test/scout/llm/chat/test_prov_cli.rb +274 -0
- data/test/scout/llm/chat/test_provenance.rb +240 -0
- data/test/scout/llm/chat/test_tool_calls.rb +38 -0
- data/test/scout/llm/test_agent.rb +13 -36
- data/test/scout/llm/test_ask.rb +75 -52
- data/test/scout/llm/test_chat.rb +107 -13
- data/test/scout/llm/test_embed.rb +48 -0
- data/test/scout/llm/test_rag.rb +23 -16
- data/test/scout/llm/test_tools.rb +12 -1
- data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
- data/test/scout/llm/tools/test_mcp.rb +5 -3
- data/test/scout/llm/tools/test_workflow.rb +23 -2
- data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
- data/test/scout/model/python/huggingface/test_causal.rb +9 -3
- data/test/scout/model/python/huggingface/test_classification.rb +11 -2
- data/test/scout/model/python/test_torch.rb +2 -0
- data/test/scout/model/python/torch/test_helpers.rb +4 -0
- data/test/scout/model/test_base.rb +4 -2
- data/test/support/availability.rb +231 -0
- data/test/support/fake_clients.rb +138 -0
- data/test/support/fixtures.rb +21 -0
- data/test/support/infrastructure_probes.rb +136 -0
- data/test/support/mock_backend.rb +215 -0
- data/test/test_helper.rb +32 -2
- metadata +99 -10
- data/doc/Agent.md +0 -327
- data/doc/Chat.md +0 -458
- data/doc/LLM.md +0 -340
- data/doc/RAG.md +0 -129
- data/scout_commands/documenter +0 -148
- data/test/scout/llm/backends/test_openai.rb +0 -192
- data/test/scout/llm/backends/test_responses.rb +0 -238
- data/test/scout/llm/test_parse.rb +0 -98
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
module Chat
|
|
2
|
+
|
|
3
|
+
# --- shorten_tools strategy configuration ---
|
|
4
|
+
|
|
5
|
+
DEFAULT_FULL_TOOL_CALLS = 0
|
|
6
|
+
DEFAULT_FULL_TOOL_OUTPUTS = 10
|
|
7
|
+
DEFAULT_MAX_TOOL_CALLS = 40
|
|
8
|
+
DEFAULT_MAX_TOOL_OUTPUTS = DEFAULT_MAX_TOOL_CALLS
|
|
9
|
+
DEFAULT_MAX_TOOL_CHARS = 100_000
|
|
10
|
+
|
|
11
|
+
def self.full_tool_calls
|
|
12
|
+
@@full_tool_calls ||= Scout::Config.get(:full_tool_calls, :prompt, :context, env: 'FULL_TOOL_CALLS')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.full_tool_outputs
|
|
16
|
+
@@full_tool_outputs ||= Scout::Config.get(:full_tool_outputs, :prompt, :context, env: 'FULL_TOOL_OUTPUTS')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.max_tool_calls
|
|
20
|
+
@@max_tool_calls ||= Scout::Config.get(:max_tool_calls, :prompt, :context, env: 'MAX_TOOL_CALLS')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.max_tool_outputs
|
|
24
|
+
@@max_tool_outputs ||= Scout::Config.get(:max_tool_outputs, :prompt, :context, env: 'MAX_TOOL_OUTPUTS', default: max_tool_calls)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.max_tool_chars
|
|
28
|
+
@@max_tool_chars ||= Scout::Config.get(:max_tool_chars, :prompt, :context, env: 'MAX_TOOL_CHARS')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# --- shorten_tools strategy implementation ---
|
|
32
|
+
|
|
33
|
+
#
|
|
34
|
+
# Walks the message array in reverse (newest-first) and applies a tiered
|
|
35
|
+
# truncation/dropping policy to +function_call+ and +function_call_output+
|
|
36
|
+
# messages. The most recent tool interactions get priority for full
|
|
37
|
+
# retention; older ones are progressively degraded.
|
|
38
|
+
#
|
|
39
|
+
# This is the **default strategy**.
|
|
40
|
+
#
|
|
41
|
+
def self.shorten_tools(messages)
|
|
42
|
+
tool_ids = []
|
|
43
|
+
tool_chars = 0
|
|
44
|
+
user_messages = 1
|
|
45
|
+
assistant_messages = 0
|
|
46
|
+
|
|
47
|
+
full_tool_calls = self.full_tool_calls || DEFAULT_FULL_TOOL_CALLS
|
|
48
|
+
full_tool_outputs = self.full_tool_outputs || DEFAULT_FULL_TOOL_OUTPUTS
|
|
49
|
+
max_tool_calls = self.max_tool_calls || DEFAULT_MAX_TOOL_CALLS
|
|
50
|
+
max_tool_outputs = self.max_tool_outputs || DEFAULT_MAX_TOOL_OUTPUTS
|
|
51
|
+
max_tool_chars = self.max_tool_chars || DEFAULT_MAX_TOOL_CHARS
|
|
52
|
+
|
|
53
|
+
full_tool_calls = full_tool_calls.to_i
|
|
54
|
+
full_tool_outputs = full_tool_outputs.to_i
|
|
55
|
+
max_tool_calls = max_tool_calls.to_i
|
|
56
|
+
max_tool_outputs = max_tool_outputs.to_i
|
|
57
|
+
max_tool_chars = max_tool_chars.to_i
|
|
58
|
+
|
|
59
|
+
messages.reverse.collect do |msg|
|
|
60
|
+
case msg[:role].to_sym
|
|
61
|
+
when :function_call
|
|
62
|
+
json = msg[:content]
|
|
63
|
+
next msg unless json
|
|
64
|
+
|
|
65
|
+
tool_call = JSON.parse json
|
|
66
|
+
name, arguments, id = tool_call.values_at 'name', 'arguments', 'id'
|
|
67
|
+
|
|
68
|
+
if tool_ids.length < full_tool_calls || user_messages == 0 || tool_chars < max_tool_chars
|
|
69
|
+
tool_chars += json.length
|
|
70
|
+
msg
|
|
71
|
+
elsif tool_ids.length > max_tool_calls
|
|
72
|
+
Log.medium "Skipped tool call #{id} #{name} #{json.length}"
|
|
73
|
+
next
|
|
74
|
+
else
|
|
75
|
+
new_arguments = {}
|
|
76
|
+
arguments.each do |k,v|
|
|
77
|
+
new_arguments[k] = String === v ? shorten_string(v) : v
|
|
78
|
+
end if arguments
|
|
79
|
+
|
|
80
|
+
next msg if arguments.values == new_arguments.values
|
|
81
|
+
|
|
82
|
+
tool_call['arguments'] = new_arguments
|
|
83
|
+
json = tool_call.to_json
|
|
84
|
+
tool_chars += json.length
|
|
85
|
+
Log.medium "Truncated tool call #{id} #{name} #{msg[:content].length} to #{json.length}"
|
|
86
|
+
msg = msg.dup
|
|
87
|
+
msg[:content] = json
|
|
88
|
+
msg
|
|
89
|
+
end
|
|
90
|
+
when :function_call_output
|
|
91
|
+
json = msg[:content]
|
|
92
|
+
next msg unless json
|
|
93
|
+
|
|
94
|
+
tool_call = JSON.parse json
|
|
95
|
+
name, content, id = tool_call.values_at 'name', 'content', 'id'
|
|
96
|
+
tool_ids << id
|
|
97
|
+
|
|
98
|
+
if tool_ids.length < full_tool_outputs || user_messages == 0 || tool_chars < max_tool_chars
|
|
99
|
+
tool_chars += json.length
|
|
100
|
+
msg
|
|
101
|
+
elsif tool_ids.length > max_tool_outputs
|
|
102
|
+
Log.medium "Skipped tool output #{id} #{name} #{json.length}"
|
|
103
|
+
next
|
|
104
|
+
else
|
|
105
|
+
tool_call['content'] = shorten_string(content, DEFAULT_SHORT_STRING_LENGTH*2)
|
|
106
|
+
next msg if content == tool_call['content']
|
|
107
|
+
json = tool_call.to_json
|
|
108
|
+
tool_chars += json.length
|
|
109
|
+
Log.medium "Truncated tool output #{id} #{name} #{msg[:content].length} to #{json.length}"
|
|
110
|
+
msg = msg.dup
|
|
111
|
+
msg[:content] = json
|
|
112
|
+
msg
|
|
113
|
+
end
|
|
114
|
+
when :user
|
|
115
|
+
user_messages += 1
|
|
116
|
+
msg
|
|
117
|
+
when :assistant
|
|
118
|
+
assistant_messages += 1
|
|
119
|
+
msg
|
|
120
|
+
else
|
|
121
|
+
msg
|
|
122
|
+
end
|
|
123
|
+
end.compact.reverse
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
require 'set'
|
|
2
|
+
|
|
3
|
+
module Chat
|
|
4
|
+
|
|
5
|
+
# --- shorten_tools_epoch strategy configuration ---
|
|
6
|
+
|
|
7
|
+
# Total tool-call count at or below which no compaction happens.
|
|
8
|
+
DEFAULT_EPOCH_TOOL_CALL_THRESHOLD = 50
|
|
9
|
+
|
|
10
|
+
# Number of most-recent tool calls to keep at full fidelity.
|
|
11
|
+
DEFAULT_EPOCH_FULL_TOOL_CALLS = 20
|
|
12
|
+
|
|
13
|
+
# Number of tool calls (before the full window) to compact (truncate).
|
|
14
|
+
DEFAULT_EPOCH_COMPACTED_TOOL_CALLS = 80
|
|
15
|
+
|
|
16
|
+
# How many new tool calls are allowed before the compaction boundary
|
|
17
|
+
# advances. Within a single epoch window the compacted prefix is frozen,
|
|
18
|
+
# maximising KV-cache / prompt-cache hits for consecutive inferences.
|
|
19
|
+
DEFAULT_EPOCH_SIZE = 20
|
|
20
|
+
|
|
21
|
+
# --- shorten_tools_epoch strategy configuration accessors ---
|
|
22
|
+
|
|
23
|
+
def self.epoch_tool_call_threshold
|
|
24
|
+
@@epoch_tool_call_threshold ||= Scout::Config.get(:epoch_tool_call_threshold, :prompt, :context,
|
|
25
|
+
env: 'EPOCH_TOOL_CALL_THRESHOLD', default: DEFAULT_EPOCH_TOOL_CALL_THRESHOLD)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.epoch_full_tool_calls
|
|
29
|
+
@@epoch_full_tool_calls ||= Scout::Config.get(:epoch_full_tool_calls, :prompt, :context,
|
|
30
|
+
env: 'EPOCH_FULL_TOOL_CALLS', default: DEFAULT_EPOCH_FULL_TOOL_CALLS)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.epoch_compacted_tool_calls
|
|
34
|
+
@@epoch_compacted_tool_calls ||= Scout::Config.get(:epoch_compacted_tool_calls, :prompt, :context,
|
|
35
|
+
env: 'EPOCH_COMPACTED_TOOL_CALLS', default: DEFAULT_EPOCH_COMPACTED_TOOL_CALLS)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.epoch_size
|
|
39
|
+
@@epoch_size ||= Scout::Config.get(:epoch_size, :prompt, :context,
|
|
40
|
+
env: 'EPOCH_SIZE', default: DEFAULT_EPOCH_SIZE)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# --- deduplication helpers (repeated-call protection) ---
|
|
44
|
+
|
|
45
|
+
# Recursively normalise a value so that hashes with differently-ordered or
|
|
46
|
+
# symbol/string keys produce the same canonical form.
|
|
47
|
+
def self.epoch_sort_value(obj)
|
|
48
|
+
case obj
|
|
49
|
+
when Hash
|
|
50
|
+
obj.transform_keys(&:to_s).sort.to_h.transform_values { |v| epoch_sort_value(v) }
|
|
51
|
+
when Array
|
|
52
|
+
obj.map { |v| epoch_sort_value(v) }
|
|
53
|
+
else
|
|
54
|
+
obj
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Build a canonical deduplication key from a tool call's name + arguments.
|
|
59
|
+
# The model-generated +id+ is deliberately excluded because it is not
|
|
60
|
+
# stable across inferences.
|
|
61
|
+
def self.epoch_dedup_key(name, arguments)
|
|
62
|
+
normalized = epoch_sort_value(arguments || {})
|
|
63
|
+
"#{name}\x00#{JSON.generate(normalized)}"
|
|
64
|
+
rescue
|
|
65
|
+
"#{name}\x00#{arguments.inspect}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# --- shorten_tools_epoch strategy implementation ---
|
|
69
|
+
|
|
70
|
+
#
|
|
71
|
+
# Cache-friendly variant of +shorten_tools+. Instead of recomputing the
|
|
72
|
+
# compactation boundary on every single inference (which constantly shifts the
|
|
73
|
+
# prefix and defeats KV-cache / prompt-cache), this strategy divides the
|
|
74
|
+
# conversation into *epochs*.
|
|
75
|
+
#
|
|
76
|
+
# Within an epoch window of +epoch_size+ tool calls the compaction boundary
|
|
77
|
+
# is frozen. This means that the compacted prefix (the messages up to and
|
|
78
|
+
# including the compacted region) is byte-for-byte identical for every
|
|
79
|
+
# inference inside that window.
|
|
80
|
+
#
|
|
81
|
+
# == Layout (newest at the bottom)
|
|
82
|
+
#
|
|
83
|
+
# [ dropped ] tool calls older than (compacted + full) → removed
|
|
84
|
+
# [ compacted ] up to +epoch_compacted_tool_calls+ tool calls, compacted
|
|
85
|
+
# [ full-recent ] +epoch_full_tool_calls+ tool calls at full fidelity
|
|
86
|
+
# [ full-new ] any tool calls that arrived after the epoch boundary
|
|
87
|
+
#
|
|
88
|
+
# The +compacted+ and +full-recent+ regions are pinned relative to the epoch
|
|
89
|
+
# boundary, not the live tool-call count, so their content stays stable
|
|
90
|
+
# until the boundary advances.
|
|
91
|
+
#
|
|
92
|
+
# == Epoch boundary calculation
|
|
93
|
+
#
|
|
94
|
+
# overflow = total_tool_calls - threshold # how many beyond threshold
|
|
95
|
+
# epoch_idx = overflow > 0 ? (overflow - 1) / epoch_size : 0
|
|
96
|
+
# pinned_total = threshold + (epoch_idx * epoch_size)
|
|
97
|
+
#
|
|
98
|
+
# The pinned_total determines where the +full-recent+ region starts. Any
|
|
99
|
+
# tool calls beyond pinned_total are treated as "new" and kept at full
|
|
100
|
+
# fidelity (they are the live portion of the prompt that changes each turn).
|
|
101
|
+
#
|
|
102
|
+
# == Example (threshold=100, full=10, compacted=40, epoch_size=10)
|
|
103
|
+
#
|
|
104
|
+
# 100 calls → compaction starts: keep 10 full, compact 40, drop 50
|
|
105
|
+
# 101 calls → pinned_total = 100, keep 10 full-recent + 1 full-new
|
|
106
|
+
# 105 calls → same pinned_total = 100, keep 10 full-recent + 5 full-new
|
|
107
|
+
# 110 calls → pinned_total = 100, keep 10 full-recent + 10 full-new
|
|
108
|
+
# 111 calls → pinned_total = 110, keep 10 full-recent + 1 full-new
|
|
109
|
+
#
|
|
110
|
+
# == Repeated-call protection
|
|
111
|
+
#
|
|
112
|
+
# When the agent loses visibility of a previous tool call (because it was
|
|
113
|
+
# dropped or heavily compacted) it may re-issue the identical call,
|
|
114
|
+
# creating infinite retry loops. To prevent this the strategy detects
|
|
115
|
+
# repeated calls — matched by (name, arguments) excluding the unstable
|
|
116
|
+
# model-generated +id+ — and ensures the *most recent* instance of every
|
|
117
|
+
# repeated call is never dropped: it is compacted instead. Older duplicate
|
|
118
|
+
# instances are dropped or compacted normally.
|
|
119
|
+
#
|
|
120
|
+
def self.shorten_tools_epoch(messages)
|
|
121
|
+
threshold = (self.epoch_tool_call_threshold || DEFAULT_EPOCH_TOOL_CALL_THRESHOLD).to_i
|
|
122
|
+
full = (self.epoch_full_tool_calls || DEFAULT_EPOCH_FULL_TOOL_CALLS).to_i
|
|
123
|
+
compacted = (self.epoch_compacted_tool_calls || DEFAULT_EPOCH_COMPACTED_TOOL_CALLS).to_i
|
|
124
|
+
epoch_sz = (self.epoch_size || DEFAULT_EPOCH_SIZE).to_i
|
|
125
|
+
|
|
126
|
+
# ---- count tool outputs in the message array ----
|
|
127
|
+
total_tool_outputs = messages.count { |m| m[:role].to_sym == :function_call_output }
|
|
128
|
+
|
|
129
|
+
return messages if total_tool_outputs <= threshold
|
|
130
|
+
return messages if full == 0 && compacted == 0
|
|
131
|
+
return messages if epoch_sz <= 0
|
|
132
|
+
|
|
133
|
+
# ---- compute the pinned epoch boundary ----
|
|
134
|
+
overflow = total_tool_outputs - threshold
|
|
135
|
+
epoch_idx = overflow > 0 ? (overflow - 1) / epoch_sz : 0
|
|
136
|
+
pinned_total = threshold + (epoch_idx * epoch_sz)
|
|
137
|
+
|
|
138
|
+
# Number of tool calls that are "new" (arrived after the epoch boundary)
|
|
139
|
+
new_calls = total_tool_outputs - pinned_total
|
|
140
|
+
|
|
141
|
+
# From the end, the regions are:
|
|
142
|
+
# [1 .. new_calls] → full-new (keep unchanged)
|
|
143
|
+
# [new_calls+1 .. new_calls+full] → full-recent (keep unchanged)
|
|
144
|
+
# [new_calls+full+1 .. new_calls+full+compacted] → compacted
|
|
145
|
+
# everything older → dropped
|
|
146
|
+
|
|
147
|
+
keep_full_count = new_calls + full
|
|
148
|
+
truncate_to = keep_full_count + compacted
|
|
149
|
+
|
|
150
|
+
# ---- detect repeated tool calls to protect from dropping ----
|
|
151
|
+
# Walk forward to identify the most-recent instance of each repeated
|
|
152
|
+
# (name, arguments) pair. Those reverse positions are added to
|
|
153
|
+
# +protected_positions+ so that the main reverse walk compacts them
|
|
154
|
+
# instead of dropping, preventing the agent from re-issuing a call it
|
|
155
|
+
# no longer remembers.
|
|
156
|
+
protected_positions = build_protected_positions(messages, total_tool_outputs)
|
|
157
|
+
|
|
158
|
+
# tool_ids counter mirrors the original shorten_tools: incremented BEFORE
|
|
159
|
+
# the check for function_call_output, so that a function_call and its
|
|
160
|
+
# paired output see the same counter value.
|
|
161
|
+
tool_ids = []
|
|
162
|
+
|
|
163
|
+
kept_messages = []
|
|
164
|
+
dropped_count = 0
|
|
165
|
+
compacted_count = 0
|
|
166
|
+
|
|
167
|
+
# Walk in reverse so we can apply the position-based policy.
|
|
168
|
+
messages.reverse.each do |msg|
|
|
169
|
+
case msg[:role].to_sym
|
|
170
|
+
when :function_call_output
|
|
171
|
+
json = msg[:content]
|
|
172
|
+
if json.nil?
|
|
173
|
+
kept_messages << msg
|
|
174
|
+
next
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
tool_call = JSON.parse json rescue nil
|
|
178
|
+
unless tool_call
|
|
179
|
+
kept_messages << msg
|
|
180
|
+
next
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
name, content, id, step = tool_call.values_at 'name', 'content', 'id', 'step'
|
|
184
|
+
tool_ids << id # increment BEFORE check (mirrors original shorten_tools)
|
|
185
|
+
|
|
186
|
+
if tool_ids.length <= keep_full_count || protected_positions.include?(tool_ids.length)
|
|
187
|
+
# full-new or full-recent (or protected repeated call) → keep unchanged
|
|
188
|
+
kept_messages << msg
|
|
189
|
+
elsif tool_ids.length <= truncate_to
|
|
190
|
+
# compacted region → truncate the content
|
|
191
|
+
new_content = shorten_string(content.to_s, DEFAULT_SHORT_STRING_LENGTH * 2, step: step)
|
|
192
|
+
if new_content != content
|
|
193
|
+
tool_call['content'] = new_content
|
|
194
|
+
new_json = tool_call.to_json
|
|
195
|
+
Log.low "Epoch: truncated tool output #{id} #{name} #{json.length} to #{new_json.length}"
|
|
196
|
+
new_msg = msg.dup
|
|
197
|
+
new_msg[:content] = new_json
|
|
198
|
+
new_msg[:compacted] = true
|
|
199
|
+
kept_messages << new_msg
|
|
200
|
+
compacted_count += 1
|
|
201
|
+
else
|
|
202
|
+
kept_messages << msg
|
|
203
|
+
end
|
|
204
|
+
else
|
|
205
|
+
# beyond compacted → drop
|
|
206
|
+
Log.low "Epoch: dropped tool output #{id} #{name} #{json.length}"
|
|
207
|
+
dropped_count += 1
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
when :function_call
|
|
211
|
+
json = msg[:content]
|
|
212
|
+
if json.nil?
|
|
213
|
+
kept_messages << msg
|
|
214
|
+
next
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
tool_call = JSON.parse json rescue nil
|
|
218
|
+
unless tool_call
|
|
219
|
+
kept_messages << msg
|
|
220
|
+
next
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
name, arguments, id = tool_call.values_at 'name', 'arguments', 'id'
|
|
224
|
+
|
|
225
|
+
# tool_ids already includes the paired output (processed before us
|
|
226
|
+
# in reverse), so we use the same boundary check.
|
|
227
|
+
if tool_ids.length <= keep_full_count || protected_positions.include?(tool_ids.length)
|
|
228
|
+
# full (or protected repeated call) → keep unchanged
|
|
229
|
+
kept_messages << msg
|
|
230
|
+
elsif tool_ids.length <= truncate_to
|
|
231
|
+
# compacted → truncate arguments
|
|
232
|
+
if arguments && arguments.any?
|
|
233
|
+
new_arguments = {}
|
|
234
|
+
arguments.each do |k, v|
|
|
235
|
+
new_arguments[k] = String === v ? shorten_string(v) : v
|
|
236
|
+
end
|
|
237
|
+
if arguments.values != new_arguments.values
|
|
238
|
+
tool_call['arguments'] = new_arguments
|
|
239
|
+
new_json = tool_call.to_json
|
|
240
|
+
Log.low "Epoch: truncated tool call #{id} #{name} #{json.length} to #{new_json.length}"
|
|
241
|
+
new_msg = msg.dup
|
|
242
|
+
new_msg[:content] = new_json
|
|
243
|
+
new_msg[:compacted] = true
|
|
244
|
+
kept_messages << new_msg
|
|
245
|
+
compacted_count += 1
|
|
246
|
+
else
|
|
247
|
+
kept_messages << msg
|
|
248
|
+
end
|
|
249
|
+
else
|
|
250
|
+
kept_messages << msg
|
|
251
|
+
end
|
|
252
|
+
else
|
|
253
|
+
# beyond compacted → drop
|
|
254
|
+
Log.low "Epoch: dropped tool call #{id} #{name} #{json.length}"
|
|
255
|
+
dropped_count += 1
|
|
256
|
+
end
|
|
257
|
+
else
|
|
258
|
+
# assistant, system, meta, etc. → always keep
|
|
259
|
+
kept_messages << msg
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
kept_messages = kept_messages.reverse
|
|
264
|
+
|
|
265
|
+
if dropped_count > 0 || compacted_count > 0
|
|
266
|
+
Log.medium "Epoch strategy: pinned_total=#{pinned_total} new_calls=#{new_calls} " \
|
|
267
|
+
"full=#{full} compacted=#{compacted} truncated=#{compacted_count} dropped=#{dropped_count} " \
|
|
268
|
+
"protected=#{protected_positions.length}"
|
|
269
|
+
|
|
270
|
+
compaction_message = {
|
|
271
|
+
role: :user,
|
|
272
|
+
content: <<~TEXT.chomp
|
|
273
|
+
=== Context Management ===
|
|
274
|
+
|
|
275
|
+
To fit within the model context window, this conversation has been
|
|
276
|
+
compacted: some tool calls arguments and tool call outputs are shown with
|
|
277
|
+
compacted content, and some have been hidden entirely. Compacted content
|
|
278
|
+
is show as '[CONTEXT-COMPACTED ...]'. Don't try to reconstruct what
|
|
279
|
+
happened involving tools that have been compacted.
|
|
280
|
+
|
|
281
|
+
Remember that the real tool call did not suffer any compactation at the
|
|
282
|
+
time it was issued, it's only compacted in the current context.
|
|
283
|
+
|
|
284
|
+
Compacted: #{compacted_count}
|
|
285
|
+
Removed: #{dropped_count}
|
|
286
|
+
|
|
287
|
+
Earlier tool results may no longer be present in the visible conversation
|
|
288
|
+
history. If information appears to be missing, it may have been removed
|
|
289
|
+
during context compaction rather than never existing. The absence of an
|
|
290
|
+
earlier tool result in the current conversation does not necessarily mean
|
|
291
|
+
that the tool has not already been executed. And care must be had accessing
|
|
292
|
+
older tool calls which may be compacted.
|
|
293
|
+
|
|
294
|
+
Repeated tool calls with the same arguments will be flagged and the very
|
|
295
|
+
last instance will be protected from removal or compactation.
|
|
296
|
+
TEXT
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
index = kept_messages.index do |msg|
|
|
300
|
+
[:function_call, :function_call_output].include?(msg[:role].to_sym)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
if index
|
|
304
|
+
kept_messages.insert(index + 1, compaction_message)
|
|
305
|
+
else
|
|
306
|
+
kept_messages << compaction_message
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
Chat.setup(kept_messages)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# --- repeated-call detection (forward pre-pass) ---
|
|
314
|
+
|
|
315
|
+
#
|
|
316
|
+
# Walk the message array forward and build a +Set+ of reverse positions
|
|
317
|
+
# (1-based from the end, matching +tool_ids.length+ in the main reverse
|
|
318
|
+
# walk) for the most-recent instance of every repeated (name, arguments)
|
|
319
|
+
# pair. Positions in this set should never be dropped.
|
|
320
|
+
#
|
|
321
|
+
def self.build_protected_positions(messages, total_tool_outputs)
|
|
322
|
+
protected = Set.new
|
|
323
|
+
|
|
324
|
+
fwd_pos = 0
|
|
325
|
+
pending_call_keys = {} # id → dedup_key (set by function_call, consumed by output)
|
|
326
|
+
key_positions = Hash.new { |h, k| h[k] = [] }
|
|
327
|
+
|
|
328
|
+
messages.each do |msg|
|
|
329
|
+
case msg[:role].to_sym
|
|
330
|
+
when :function_call
|
|
331
|
+
json = msg[:content]
|
|
332
|
+
next unless json
|
|
333
|
+
tool_call = JSON.parse json rescue nil
|
|
334
|
+
next unless tool_call
|
|
335
|
+
name = tool_call['name']
|
|
336
|
+
arguments = tool_call['arguments']
|
|
337
|
+
id = tool_call['id']
|
|
338
|
+
pending_call_keys[id] = Chat.epoch_dedup_key(name, arguments)
|
|
339
|
+
when :function_call_output
|
|
340
|
+
json = msg[:content]
|
|
341
|
+
next unless json
|
|
342
|
+
tool_call = JSON.parse json rescue nil
|
|
343
|
+
next unless tool_call
|
|
344
|
+
fwd_pos += 1
|
|
345
|
+
id = tool_call['id']
|
|
346
|
+
key = pending_call_keys.delete(id)
|
|
347
|
+
next unless key
|
|
348
|
+
key_positions[key] << fwd_pos
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
key_positions.each do |key, positions|
|
|
353
|
+
next if positions.length <= 1
|
|
354
|
+
most_recent_fwd = positions.max
|
|
355
|
+
reverse_pos = total_tool_outputs - most_recent_fwd + 1
|
|
356
|
+
protected << reverse_pos
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
unless protected.empty?
|
|
360
|
+
Log.low "Epoch: protecting #{protected.length} repeated call(s) from dropping"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
protected
|
|
364
|
+
end
|
|
365
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require_relative 'prompt/shorten_tools'
|
|
2
|
+
require_relative 'prompt/shorten_tools_epoch'
|
|
3
|
+
|
|
4
|
+
module Chat
|
|
5
|
+
|
|
6
|
+
REGISTERED_STRATEGIES = {}
|
|
7
|
+
DEFAULT_CONTEXT_STRATEGY = %w(shorten_tools_epoch)
|
|
8
|
+
DEFAULT_SHORT_STRING_LENGTH = 200
|
|
9
|
+
DEFAULT_SHORT_JSON_LENGTH = 2000
|
|
10
|
+
|
|
11
|
+
# --- Shared utility used by all strategies ---
|
|
12
|
+
|
|
13
|
+
def self.shorten_string(string, size = DEFAULT_SHORT_STRING_LENGTH, step: nil)
|
|
14
|
+
new = Log.truncate_string(string, size)
|
|
15
|
+
if new.length < string.length
|
|
16
|
+
new = ['CONTEXT-COMPACTED', 'Historical account compacted for context efficiency; it does not faithfully represent what happened', "Original content length: #{string.length}"]
|
|
17
|
+
if step
|
|
18
|
+
new << "Full output found in job: #{step}"
|
|
19
|
+
end
|
|
20
|
+
new << 'Do not execute, copy or otherwise use this string as-is.'
|
|
21
|
+
new << "Preview: <<#{new}>>"
|
|
22
|
+
new = "[#{new * ' - '}]"
|
|
23
|
+
end
|
|
24
|
+
new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# --- Prompt strategy dispatcher ---
|
|
28
|
+
|
|
29
|
+
def self.prepare_prompt(prompt, prompt_strategies = nil)
|
|
30
|
+
return prompt_strategies.call(prompt) if Proc === prompt_strategies
|
|
31
|
+
prompt_strategies = DEFAULT_CONTEXT_STRATEGY if prompt_strategies.nil?
|
|
32
|
+
prompt_strategies = prompt_strategies.split(',') if String === prompt_strategies
|
|
33
|
+
prompt_strategies.each do |strategy|
|
|
34
|
+
prompt = case strategy
|
|
35
|
+
when 'shorten_tools'
|
|
36
|
+
Chat.shorten_tools(prompt)
|
|
37
|
+
when 'shorten_tools_epoch'
|
|
38
|
+
Chat.shorten_tools_epoch(prompt)
|
|
39
|
+
when 'none'
|
|
40
|
+
prompt
|
|
41
|
+
else
|
|
42
|
+
strategy_proc = REGISTERED_STRATEGIES[strategy]
|
|
43
|
+
strategy_proc.call(prompt)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
return prompt
|
|
47
|
+
end
|
|
48
|
+
end
|