scout-ai 1.2.5 → 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.
Files changed (164) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +111 -31
  3. data/README.md +171 -320
  4. data/Rakefile +17 -1
  5. data/VERSION +1 -1
  6. data/doc/Improvements.md +325 -0
  7. data/doc/StartHere.md +110 -0
  8. data/doc/developer/Architecture.md +126 -0
  9. data/doc/developer/Backends.md +199 -0
  10. data/doc/developer/ChatLifecycle.md +183 -0
  11. data/doc/developer/DelegationInternals.md +295 -0
  12. data/doc/developer/DesignPrinciples.md +245 -0
  13. data/doc/developer/PromptProcessing.md +292 -0
  14. data/doc/developer/Provenance.md +317 -0
  15. data/doc/user/BuildingAgents.md +345 -0
  16. data/doc/user/Cookbook.md +333 -0
  17. data/doc/user/CoreConcepts.md +181 -0
  18. data/doc/user/Delegation.md +191 -0
  19. data/doc/user/GettingStarted.md +159 -0
  20. data/doc/user/ManagingContext.md +163 -0
  21. data/doc/user/MultiAgentWorkflows.md +256 -0
  22. data/doc/user/Python.md +159 -0
  23. data/doc/user/RunningInference.md +200 -0
  24. data/doc/user/ToolCalling.md +193 -0
  25. data/doc/user/WritingChats.md +197 -0
  26. data/lib/scout/llm/agent/chat.rb +62 -12
  27. data/lib/scout/llm/agent/delegate.rb +274 -65
  28. data/lib/scout/llm/agent/iterate.rb +2 -2
  29. data/lib/scout/llm/agent/save.rb +273 -0
  30. data/lib/scout/llm/agent/workflow.rb +164 -0
  31. data/lib/scout/llm/agent.rb +86 -61
  32. data/lib/scout/llm/ask.rb +36 -6
  33. data/lib/scout/llm/backends/anthropic.rb +9 -1
  34. data/lib/scout/llm/backends/bedrock.rb +15 -3
  35. data/lib/scout/llm/backends/default.rb +129 -89
  36. data/lib/scout/llm/backends/glm.rb +58 -0
  37. data/lib/scout/llm/backends/huggingface.rb +13 -1
  38. data/lib/scout/llm/backends/ollama.rb +13 -0
  39. data/lib/scout/llm/backends/openwebui.rb +8 -3
  40. data/lib/scout/llm/chat/agent_meta.rb +264 -0
  41. data/lib/scout/llm/chat/annotation.rb +37 -14
  42. data/lib/scout/llm/chat/parse.rb +28 -6
  43. data/lib/scout/llm/chat/persist.rb +25 -0
  44. data/lib/scout/llm/chat/process/clear.rb +19 -1
  45. data/lib/scout/llm/chat/process/files.rb +16 -1
  46. data/lib/scout/llm/chat/process/meta.rb +422 -32
  47. data/lib/scout/llm/chat/process/options.rb +21 -1
  48. data/lib/scout/llm/chat/process/tools.rb +49 -12
  49. data/lib/scout/llm/chat/process.rb +4 -0
  50. data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
  51. data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
  52. data/lib/scout/llm/chat/prompt.rb +48 -0
  53. data/lib/scout/llm/chat/provenance.rb +775 -0
  54. data/lib/scout/llm/chat/tool_calls.rb +76 -0
  55. data/lib/scout/llm/chat.rb +18 -2
  56. data/lib/scout/llm/embed.rb +8 -3
  57. data/lib/scout/llm/image.rb +86 -0
  58. data/lib/scout/llm/rag.rb +3 -3
  59. data/lib/scout/llm/tools/call.rb +159 -10
  60. data/lib/scout/llm/tools/knowledge_base.rb +1 -1
  61. data/lib/scout/llm/tools/workflow.rb +13 -5
  62. data/lib/scout-ai.rb +1 -0
  63. data/research/ChatAnalyst-required-changes.md +167 -0
  64. data/research/agent-delegation-analysis.md +810 -0
  65. data/research/agent-meta-provenance-integration-plan.md +622 -0
  66. data/research/agent-workflow-analysis.md +1120 -0
  67. data/research/backends-analysis.md +836 -0
  68. data/research/chat-core-analysis.md +946 -0
  69. data/research/chatanalyst-provenance/00-baseline.md +30 -0
  70. data/research/chatanalyst-provenance/01-repo-map.md +60 -0
  71. data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
  72. data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
  73. data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
  74. data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
  75. data/research/chatanalyst-provenance/07-critic-review.md +25 -0
  76. data/research/chatanalyst-provenance/final-report.md +45 -0
  77. data/research/chatanalyst-provenance/resumption.md +37 -0
  78. data/research/coding-philosophy-analysis.md +928 -0
  79. data/research/commands-analysis.md +947 -0
  80. data/research/multi-agent-patterns-analysis.md +853 -0
  81. data/research/prompt-strategies-analysis.md +630 -0
  82. data/research/prov-verbosity-fix-notes.md +77 -0
  83. data/research/provenance-analysis.md +469 -0
  84. data/research/provenance-navigation-design.md +640 -0
  85. data/research/synthesis-report.md +487 -0
  86. data/research/tools-system-analysis.md +779 -0
  87. data/scout-ai.gemspec +97 -13
  88. data/scout_commands/agent/ask +13 -3
  89. data/scout_commands/agent/kb +2 -0
  90. data/scout_commands/llm/ask +11 -4
  91. data/scout_commands/llm/md +76 -0
  92. data/scout_commands/llm/prov +602 -0
  93. data/scout_commands/llm/word +71 -0
  94. data/share/word/reference.docx +0 -0
  95. data/test/etc/AI/mock.yaml +11 -0
  96. data/test/fixtures/backends/anthropic.json +19 -0
  97. data/test/fixtures/backends/anthropic_tool_use.json +24 -0
  98. data/test/fixtures/backends/bedrock.json +8 -0
  99. data/test/fixtures/backends/bedrock_embedding.json +3 -0
  100. data/test/fixtures/backends/bedrock_tool_use.json +17 -0
  101. data/test/fixtures/backends/ollama.json +16 -0
  102. data/test/fixtures/backends/ollama_tool_call.json +27 -0
  103. data/test/fixtures/backends/openai_chat.json +21 -0
  104. data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
  105. data/test/fixtures/backends/responses.json +33 -0
  106. data/test/fixtures/backends/responses_tool_call.json +28 -0
  107. data/test/integration/README.md +32 -0
  108. data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
  109. data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
  110. data/test/integration/scout/llm/backends/test_relay.rb +52 -0
  111. data/test/integration/scout/llm/test_infrastructure.rb +74 -0
  112. data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
  113. data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
  114. data/test/integration/scout/model/test_base.rb +91 -0
  115. data/test/scout/llm/agent/test_chat.rb +8 -2
  116. data/test/scout/llm/agent/test_save.rb +413 -0
  117. data/test/scout/llm/agent/test_workflow.rb +110 -0
  118. data/test/scout/llm/backends/test_anthropic.rb +93 -10
  119. data/test/scout/llm/backends/test_bedrock.rb +118 -2
  120. data/test/scout/llm/backends/test_ollama.rb +70 -20
  121. data/test/scout/llm/backends/test_openwebui.rb +42 -40
  122. data/test/scout/llm/backends/test_relay.rb +4 -2
  123. data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
  124. data/test/scout/llm/chat/process/test_meta.rb +518 -0
  125. data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
  126. data/test/scout/llm/chat/test_agent_meta.rb +357 -0
  127. data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
  128. data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
  129. data/test/scout/llm/chat/test_parse.rb +70 -15
  130. data/test/scout/llm/chat/test_prov_cli.rb +274 -0
  131. data/test/scout/llm/chat/test_provenance.rb +240 -0
  132. data/test/scout/llm/chat/test_tool_calls.rb +38 -0
  133. data/test/scout/llm/test_agent.rb +13 -36
  134. data/test/scout/llm/test_ask.rb +75 -52
  135. data/test/scout/llm/test_chat.rb +107 -13
  136. data/test/scout/llm/test_embed.rb +48 -0
  137. data/test/scout/llm/test_rag.rb +23 -16
  138. data/test/scout/llm/test_tools.rb +12 -1
  139. data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
  140. data/test/scout/llm/tools/test_mcp.rb +5 -3
  141. data/test/scout/llm/tools/test_workflow.rb +23 -2
  142. data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
  143. data/test/scout/model/python/huggingface/test_causal.rb +9 -3
  144. data/test/scout/model/python/huggingface/test_classification.rb +11 -2
  145. data/test/scout/model/python/test_torch.rb +2 -0
  146. data/test/scout/model/python/torch/test_helpers.rb +4 -0
  147. data/test/scout/model/test_base.rb +4 -2
  148. data/test/support/availability.rb +231 -0
  149. data/test/support/fake_clients.rb +138 -0
  150. data/test/support/fixtures.rb +21 -0
  151. data/test/support/infrastructure_probes.rb +136 -0
  152. data/test/support/mock_backend.rb +215 -0
  153. data/test/test_helper.rb +32 -2
  154. metadata +96 -12
  155. data/doc/Agent.md +0 -354
  156. data/doc/Chat.md +0 -481
  157. data/doc/LLM.md +0 -356
  158. data/doc/PythonAgentTasks.md +0 -333
  159. data/doc/RAG.md +0 -129
  160. data/doc/USER_GUIDE.md +0 -572
  161. data/scout_commands/documenter +0 -148
  162. data/test/scout/llm/backends/test_openai.rb +0 -192
  163. data/test/scout/llm/backends/test_responses.rb +0 -238
  164. data/test/scout/llm/test_parse.rb +0 -98
@@ -2,15 +2,132 @@ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
2
  require File.expand_path(__FILE__).sub(%r(.*/test/), '').sub(/test_(.*)\.rb/,'\1')
3
3
 
4
4
  class TestLLMBedrock < Test::Unit::TestCase
5
+ # Offline Bedrock coverage: LLM::Bedrock.ask/embed accept options[:client]
6
+ # (an object with invoke_model(model_id:, content_type:, body:) returning
7
+ # something whose .body.string is JSON), so FakeBedrockClient exercises the
8
+ # full request construction + response parsing path without AWS credentials.
9
+ #
10
+ # The old real-service versions are kept disabled below (_test_*).
11
+
12
+ # ScoutCoder: LLM::Bedrock is not required by lib/scout-ai.rb; each test has
13
+ # to require 'scout/llm/backends/bedrock' itself (same as LLM.ask does when
14
+ # dispatching to the :bedrock backend).
15
+ def test_ask
16
+ require 'scout/llm/backends/bedrock'
17
+ client = TestFixtures.bedrock_client('backends/bedrock')
18
+
19
+ response = LLM::Bedrock.ask 'user: say hi to bedrock',
20
+ client: client,
21
+ model: 'anthropic.claude-3-sonnet-20240229-v1:0',
22
+ model_max_tokens: 100
23
+
24
+ assert_equal 'Mock answer from Bedrock', response
25
+
26
+ assert_equal 1, client.calls.length
27
+ call = client.calls.first
28
+ assert_equal 'anthropic.claude-3-sonnet-20240229-v1:0', call[:model_id]
29
+ assert_equal 'application/json', call[:content_type]
30
+ # ScoutCoder: IndiferentHash#pretty_print takes 0 args, so assert on
31
+ # individual keys instead of pp-ing the recorded parameters.
32
+ assert_equal 100, call[:body]['max_tokens']
33
+ assert call[:body]['messages'].any? { |m| m['role'] == 'user' }
34
+ assert call[:body]['messages'].any? { |m| m['content'].to_s.include?('say hi to bedrock') }
35
+ end
36
+
37
+ def test_ask_prompt_type
38
+ require 'scout/llm/backends/bedrock'
39
+ client = TestFixtures.bedrock_client('backends/bedrock')
40
+
41
+ response = LLM::Bedrock.ask 'user: say hi through the prompt endpoint',
42
+ client: client, type: :prompt,
43
+ model: 'meta.llama3-8b-instruct-v1:0',
44
+ model_max_tokens: 100
45
+
46
+ assert_equal 'Mock answer from Bedrock', response
47
+
48
+ body = client.calls.first[:body]
49
+ assert body.include?('prompt')
50
+ assert body['prompt'].to_s.include?('say hi through the prompt endpoint')
51
+ end
52
+
53
+ def test_embeddings
54
+ require 'scout/llm/backends/bedrock'
55
+ client = TestFixtures.bedrock_client('backends/bedrock_embedding')
56
+
57
+ emb = LLM::Bedrock.embed 'Some text', client: client,
58
+ model: 'amazon.titan-embed-text-v1'
59
+
60
+ assert(Float === emb.first)
61
+ assert_equal [0.1, 0.2, 0.3], emb
62
+ assert_equal 1, client.calls.length
63
+
64
+ call = client.calls.first
65
+ assert_equal 'amazon.titan-embed-text-v1', call[:model_id]
66
+ assert_equal 'Some text', call[:body]['inputText']
67
+ end
68
+
69
+ # Tool loop: the first payload carries a tool_call content entry, the block
70
+ # answers it, and the second payload is the final text answer.
71
+ def test_tool_loop
72
+ require 'scout/llm/backends/bedrock'
73
+ tools = [
74
+ {
75
+ "type": "function",
76
+ "function": {
77
+ "name": "get_current_temperature",
78
+ "description": "Get the current temperature for a specific location",
79
+ "parameters": {
80
+ "type": "object",
81
+ "properties": {
82
+ "location": { "type": "string",
83
+ "description": "The city and state, e.g., San Francisco, CA" },
84
+ "unit": { "type": "string", "enum": ["Celsius", "Fahrenheit"],
85
+ "description": "The temperature unit to use." }
86
+ },
87
+ "required": ["location", "unit"]
88
+ }
89
+ }
90
+ }
91
+ ]
92
+
93
+ client = TestFixtures.bedrock_client('backends/bedrock_tool_use', 'backends/bedrock')
94
+
95
+ calls_seen = []
96
+ response = LLM::Bedrock.ask 'user: What is the weather in London? Should I take an umbrella? Use the tool.',
97
+ tools: tools, client: client,
98
+ model: 'anthropic.claude-3-sonnet-20240229-v1:0',
99
+ model_max_tokens: 100 do |name, arguments|
100
+ calls_seen << [name, arguments]
101
+ "It's 15 degrees and raining."
102
+ end
103
+
104
+ assert_equal 'Mock answer from Bedrock', response
105
+
106
+ # the block was reached with the unwrapped tool call
107
+ assert_equal 1, calls_seen.length
108
+ assert_equal 'get_current_temperature', calls_seen.first.first
109
+ assert_equal 'London', calls_seen.first.last['location']
110
+
111
+ # two invoke_model rounds, the second one carrying the tool response
112
+ assert_equal 2, client.calls.length
113
+ sent = client.calls.last[:body]['messages']
114
+ assert sent.any? { |m| m['role'] == 'tool' }
115
+ tool_messages = sent.select { |m| m['role'] == 'tool' }
116
+ assert_equal 'It\'s 15 degrees and raining.', tool_messages.last['content']
117
+ assert_equal 'call_1', tool_messages.last['id']
118
+ end
119
+
5
120
  def _test_ask
121
+ # Real-service version: see the offline tests above. Requires AWS
122
+ # credentials and network.
6
123
  prompt =<<-EOF
7
124
  say hi
8
125
  EOF
9
126
  ppp LLM::Bedrock.ask prompt, model: "anthropic.claude-3-sonnet-20240229-v1:0", model_max_tokens: 100, model_anthropic_version: 'bedrock-2023-05-31'
10
127
  end
11
128
 
12
-
13
129
  def _test_embeddings
130
+ # Real-service version: see test_embeddings above.
14
131
  Log.severity = 0
15
132
  text =<<-EOF
16
133
  Some text
@@ -57,4 +174,3 @@ What is the weather in London. Should I take my umbrella? Use the provided tool
57
174
  ppp response
58
175
  end
59
176
  end
60
-
@@ -3,14 +3,72 @@ require File.expand_path(__FILE__).sub(%r(.*/test/), '').sub(/test_(.*)\.rb/,'\1
3
3
 
4
4
  class TestLLMOllama < Test::Unit::TestCase
5
5
 
6
- def _test_ask
7
- Log.severity = 0
6
+ def test_ask
7
+ client = TestFixtures.ollama_client('backends/ollama')
8
+ res = LLM::OLlama.ask 'user: write a script that sorts files in a directory',
9
+ client: client, model: 'mistral', mode: 'chat', persist: false
10
+
11
+ assert_equal 'Mock answer from Ollama', res
12
+ assert_equal 1, client.calls.length
13
+ assert_equal 'mistral', client.calls.first[:model]
14
+ assert client.calls.first[:messages].any? { |m| m[:role].to_s == 'user' }
15
+ end
16
+
17
+ def test_tool
8
18
  prompt =<<-EOF
9
- system: you are a coding helper that only write code and inline comments. No extra explanations or comentary
10
- system: Avoid using backticks ``` to format code.
11
- user: write a script that sorts files in a directory
19
+ What is the weather in London. Should I take an umbrella?
12
20
  EOF
13
- ppp LLM::OLlama.ask prompt, model: 'mistral', mode: 'chat'
21
+
22
+ tools = [
23
+ {
24
+ "type": "function",
25
+ "function": {
26
+ "name": "get_current_temperature",
27
+ "description": "Get the current temperature for a specific location",
28
+ "parameters": {
29
+ "type": "object",
30
+ "properties": {
31
+ "location": {
32
+ "type": "string",
33
+ "description": "The city and state, e.g., San Francisco, CA"
34
+ },
35
+ "unit": {
36
+ "type": "string",
37
+ "enum": ["Celsius", "Fahrenheit"],
38
+ "description": "The temperature unit to use. Infer this from the user's location."
39
+ }
40
+ },
41
+ "required": ["location", "unit"]
42
+ }
43
+ }
44
+ },
45
+ ]
46
+
47
+ client = TestFixtures.ollama_client('backends/ollama_tool_call', 'backends/ollama')
48
+ respose = LLM::OLlama.ask prompt, model: 'gpt-oss',
49
+ client: client, tool_choice: 'required',
50
+ tools: tools, persist: false do |name,arguments|
51
+ "It's raining cats and dogs"
52
+ end
53
+
54
+ assert_equal 'Mock answer from Ollama', respose
55
+ assert_equal 2, client.calls.length
56
+
57
+ # ScoutCoder: LLM::OLlama#query calls client.chat(parameters) positionally
58
+ # (no `parameters:` kwarg) and the API returns an Array of chunk hashes.
59
+ sent_tools = client.calls.first[:tools]
60
+ assert sent_tools.any? { |t| (t.dig(:function, :name) || t.dig('function', 'name')) == 'get_current_temperature' }
61
+ end
62
+
63
+ def test_embeddings
64
+ payload = [{ 'embeddings' => [[0.1, 0.2, 0.3]] }]
65
+ client = FakeOllamaClient.new(payload)
66
+
67
+ emb = LLM::OLlama.embed 'Some text', client: client, model: 'mxbai-embed-large'
68
+
69
+ assert(Float === emb.first)
70
+ assert_equal [0.1, 0.2, 0.3], emb
71
+ assert_equal 'Some text', client.calls.first[:input]
14
72
  end
15
73
 
16
74
  def _test_tool_call_output
@@ -89,22 +147,14 @@ What is the weather in London. Should I take an umbrella?
89
147
  ppp respose
90
148
  end
91
149
 
92
- def _test_embeddings
93
- Log.severity = 0
94
- text =<<-EOF
95
- Some text
96
- EOF
97
- emb = LLM::OLlama.embed text, model: 'mxbai-embed-large', url: 'localhost:3331'
98
- assert(Float === emb.first)
99
- end
100
-
101
150
  def test_embedding_array
102
- Log.severity = 0
103
- text =<<-EOF
104
- Some text
105
- EOF
106
- emb = LLM::OLlama.embed [text], model: 'mxbai-embed-large', url: 'localhost:3331'
151
+ payload = [{ 'embeddings' => [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]] }]
152
+ client = FakeOllamaClient.new(payload)
153
+
154
+ emb = LLM::OLlama.embed ['Some text', 'More text'], client: client, model: 'mxbai-embed-large'
155
+
107
156
  assert(Float === emb.first.first)
157
+ assert_equal [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], emb
108
158
  end
109
159
  end
110
160
 
@@ -2,13 +2,12 @@ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
2
  require File.expand_path(__FILE__).sub(%r(.*/test/), '').sub(/test_(.*)\.rb/,'\1')
3
3
 
4
4
  class TestOpenWebUI < Test::Unit::TestCase
5
- def test_gepeto
5
+ # Real https://gepeto.bsc.es/api version moved to
6
+ # test/integration/scout/llm/backends/test_openwebui.rb (no client seam:
7
+ # LLM::OpenWebUIMethods#query posts through RestClient.post with a plain
8
+ # Hash "client").
9
+ def _test_gepeto
6
10
  Log.severity = 0
7
- prompt =<<-EOF
8
- system: you are a coding helper that only write code and comments without formatting so that it can work directly, avoid the initial and end commas ```.
9
- user: write a script that sorts files in a directory
10
- EOF
11
-
12
11
  prompt =<<-EOF
13
12
  user: write a script that sorts files in a directory
14
13
  EOF
@@ -16,42 +15,45 @@ user: write a script that sorts files in a directory
16
15
  ppp LLM::OpenWebUI.ask prompt, model: 'qwen3-vl:30b', url: "https://gepeto.bsc.es/api"
17
16
  end
18
17
 
19
- def _test_tool
20
- prompt =<<-EOF
21
- What is the weather in London. Should I take an umbrella?
22
- EOF
18
+ # Offline: stub RestClient.post so only request construction and response
19
+ # parsing are exercised. OpenWebUI is OpenAI-compatible, so the openai_chat
20
+ # fixture is replayed as the response body.
21
+ def test_ask_request_construction
22
+ fixture = TestFixtures.fixture('backends/openai_chat')
23
+ expected_answer = fixture.dig('choices', 0, 'message', 'content')
24
+
25
+ recorded = []
26
+ original = RestClient.method(:post)
23
27
 
24
- tools = [
25
- {
26
- "type": "function",
27
- "function": {
28
- "name": "get_current_temperature",
29
- "description": "Get the current temperature for a specific location",
30
- "parameters": {
31
- "type": "object",
32
- "properties": {
33
- "location": {
34
- "type": "string",
35
- "description": "The city and state, e.g., San Francisco, CA"
36
- },
37
- "unit": {
38
- "type": "string",
39
- "enum": ["Celsius", "Fahrenheit"],
40
- "description": "The temperature unit to use. Infer this from the user's location."
41
- }
42
- },
43
- "required": ["location", "unit"]
44
- }
45
- }
46
- },
47
- ]
48
-
49
- sss 0
50
- respose = LLM::OpenWebUI.ask prompt, model: 'gemma2:latest', tool_choice: 'required', tools: tools do |name,arguments|
51
- "It's raining cats and dogs"
28
+ RestClient.define_singleton_method(:post) do |url, payload, headers|
29
+ recorded << {url: url, payload: JSON.parse(payload), headers: headers}
30
+ body = Struct.new(:body).new(fixture.to_json)
31
+ body
52
32
  end
53
33
 
54
- ppp respose
34
+ begin
35
+ answer = LLM::OpenWebUI.ask 'user: write a script that sorts files in a directory offline',
36
+ model: 'qwen3-vl:30b', url: 'https://openwebui.example/api',
37
+ key: 'test-key', persist: false
38
+ ensure
39
+ RestClient.singleton_class.send(:define_method, :post, original)
40
+ end
41
+
42
+ assert_equal expected_answer, answer
43
+
44
+ assert_equal 1, recorded.length
45
+ call = recorded.first
46
+
47
+ assert call[:url].end_with?('chat/completions')
48
+ assert call[:url].start_with?('https://openwebui.example/api')
49
+
50
+ payload = call[:payload]
51
+ assert_equal 'qwen3-vl:30b', payload['model']
52
+ assert payload['messages'].any? { |m| m['role'] == 'user' }
53
+
54
+ headers = call[:headers]
55
+ assert headers['Authorization'] || headers[:Authorization]
56
+ assert_equal 'Bearer test-key', (headers['Authorization'] || headers[:Authorization]).to_s
57
+ assert((headers['Content-Type'] || headers[:Content_Type]).to_s.include?('application/json'))
55
58
  end
56
59
  end
57
-
@@ -2,9 +2,11 @@ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
2
  require File.expand_path(__FILE__).sub(%r(.*/test/), '').sub(/test_(.*)\.rb/,'\1')
3
3
 
4
4
  class TestRelay < Test::Unit::TestCase
5
- def test_ask
5
+ # Real scp/ssh relay version moved to
6
+ # test/integration/scout/llm/backends/test_relay.rb: LLM::Relay shells out
7
+ # to `scp` and has no client seam, so the unit copy stays disabled.
8
+ def _test_ask
6
9
  Scout::Config.set(:server, 'localhost', :relay)
7
10
  ppp LLM::Relay.ask 'Say hi', model: 'gemma2'
8
11
  end
9
12
  end
10
-
@@ -0,0 +1,131 @@
1
+ # Reusable offline fixtures for agent_meta provenance tests (plan fixtures C,
2
+ # D, E; also intended for the later token-collector rounds). Everything runs
3
+ # on TmpFile.with_dir directories plus persisted-style chat text; no providers.
4
+ #
5
+ # Helpers provided:
6
+ # write_chat(dir, name, text) -> chat file path
7
+ # meta_receipt(content) -> agent_meta entry Hash
8
+ # receipt_output(call_id, agent_meta, ...) -> JSON envelope String
9
+ # receipt_chat_text(receipts, extra: nil) -> persisted chat text
10
+ # plain_delegation_chat(job_path) -> persisted chat text
11
+ # make_job(dir, ref, dependencies:, logs:) -> job path
12
+ # visit_signature(visits) -> comparable Array
13
+ # fixture_c(dir) -> C/D layout paths
14
+ # truncated_receipt_chat(call_id, agent_meta) -> fixture H text
15
+ require 'fileutils'
16
+ require 'json'
17
+
18
+ module AgentMetaFixtures
19
+ # Write persisted-style chat text and return its absolute path.
20
+ def write_chat(dir, name, text)
21
+ path = File.expand_path(File.join(dir, name))
22
+ FileUtils.mkdir_p(File.dirname(path))
23
+ File.write(path, text)
24
+ path
25
+ end
26
+
27
+ # One agent_meta receipt entry with direct meta content, e.g.
28
+ # meta_receipt('pt=10 ct=4 tt=14 inference_id=w1') or
29
+ # meta_receipt('job=Worker/ask/Default_w').
30
+ def meta_receipt(content)
31
+ {role: 'meta', content: content}
32
+ end
33
+
34
+ # JSON payload of a function_call_output envelope carrying agent_meta.
35
+ def receipt_output(call_id, agent_meta, name: 'ask', content: 'child answer')
36
+ {name: name, content: content, id: call_id, agent_meta: agent_meta}.to_json
37
+ end
38
+
39
+ # Persisted chat text with one paired ask call per receipt entry. Hash keys
40
+ # are call ids, values are the agent_meta payloads (Arrays, Strings, ...).
41
+ # `extra` lines are appended after the receipts (e.g. local meta lines).
42
+ #
43
+ # Message indexes produced by Chat.parse (single user turn, no leading
44
+ # empty user message since 49c0d20):
45
+ # 0 user, then per receipt: function_call, function_call_output.
46
+ def receipt_chat_text(receipts, extra: nil)
47
+ lines = ['user: Run the worker']
48
+ receipts.each do |call_id, agent_meta|
49
+ lines << 'function_call: ' + %({"name":"ask","arguments":{},"id":"#{call_id}"})
50
+ lines << 'function_call_output: ' + receipt_output(call_id, agent_meta)
51
+ end
52
+ lines.concat(Array(extra)) if extra
53
+ lines << 'assistant: done'
54
+ lines * "\n" + "\n"
55
+ end
56
+
57
+ # Persisted chat text delegating to `job_path` through an ordinary local
58
+ # `meta: job=` message (no receipts at all).
59
+ def plain_delegation_chat(job_path)
60
+ "user: Run the worker\nmeta: job=#{job_path}\nassistant: done\n"
61
+ end
62
+
63
+ # Create a job layout under `dir` for the relative reference `ref`
64
+ # (e.g. 'Worker/ask/Default_w'): the result file, a .info sidecar with
65
+ # `dependencies` (always written, matching scout-gear Step), and log chats
66
+ # written under `<job>.files/log/<name>` (Hash name -> chat text). Returns
67
+ # the job path.
68
+ def make_job(dir, ref, result: 'answer', dependencies: [], logs: {})
69
+ path = File.expand_path(File.join(dir, ref))
70
+ FileUtils.mkdir_p(File.dirname(path))
71
+ File.write(path, result)
72
+ File.write(path + '.info', {dependencies: dependencies}.to_json)
73
+ logs.each do |name, text|
74
+ log_path = File.join(path + '.files', 'log', name)
75
+ FileUtils.mkdir_p(File.dirname(log_path))
76
+ File.write(log_path, text)
77
+ end
78
+ path
79
+ end
80
+
81
+ # Comparable signature of traverse_provenance visits:
82
+ # [kind, path, relation, first_visit].
83
+ def visit_signature(visits)
84
+ visits.collect do |kind, object, _parent_kind, _parent, relation, first|
85
+ [kind, object.respond_to?(:path) ? object.path.to_s : object.to_s, relation, first]
86
+ end
87
+ end
88
+
89
+ # Fixture C layout (plan fixtures C/D): a parent receipt points at a Worker
90
+ # job whose log chat has two direct metas (w1, w2), a nested receipt for a
91
+ # Critic job (with one direct meta c1), and a dependency job. The parent
92
+ # chat also carries an ordinary local `meta: job=` reference to the Critic so
93
+ # both discovery paths coexist, plus one receipt-only direct meta (shadow).
94
+ # Returns [parent_chat_path, worker_job_path, critic_job_path, dep_job_path].
95
+ def fixture_c(dir)
96
+ critic = make_job(dir, 'Critic/ask/Default_c')
97
+ dep = make_job(dir, 'Dep/load/Default_1')
98
+
99
+ worker_log = receipt_chat_text(
100
+ {'wc1' => [meta_receipt('pt=30 ct=10 tt=40 inference_id=c1'),
101
+ meta_receipt("job=#{critic}")]},
102
+ extra: ['meta: pt=100 ct=50 tt=150 inference_id=w1',
103
+ 'meta: pt=20 ct=10 tt=30 inference_id=w2']
104
+ )
105
+ worker = make_job(dir, 'Worker/ask/Default_w',
106
+ dependencies: [dep], logs: {'agent.chat' => worker_log})
107
+
108
+ parent = write_chat(dir, 'parent.chat',
109
+ receipt_chat_text(
110
+ {'p1' => [meta_receipt('pt=10 tt=12 inference_id=shadow'),
111
+ meta_receipt("job=#{worker}")]},
112
+ extra: ["meta: job=#{critic}"]
113
+ ))
114
+
115
+ [parent, worker, critic, dep]
116
+ end
117
+
118
+ # Fixture H: the output content is the standard truncation exception JSON
119
+ # (error: :truncated) exactly as LLM.process_calls serializes it, while the
120
+ # agent_meta receipts survive in the same envelope.
121
+ def truncated_receipt_chat(call_id, agent_meta, name: 'ask', characters: 90_000)
122
+ exception_msg = "Function #{name} #{call_id} was executed successfully, but it returned #{characters} characters, which is more than the maximum of 30000. To protect the model context window this result was not returned."
123
+ content = {exception: exception_msg, stack: ['a', 'b']}.to_json
124
+ payload = {name: name, content: content, id: call_id, error: :truncated,
125
+ agent_meta: agent_meta}.to_json
126
+ "user: Run\n" +
127
+ %({"name":"#{name}","arguments":{},"id":"#{call_id}"}).sub(/^/, 'function_call: ') + "\n" +
128
+ "function_call_output: #{payload}\n" +
129
+ "assistant: done\n"
130
+ end
131
+ end