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.
Files changed (174) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +138 -50
  3. data/README.md +171 -290
  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 +61 -11
  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 +62 -17
  33. data/lib/scout/llm/backends/anthropic.rb +9 -2
  34. data/lib/scout/llm/backends/bedrock.rb +15 -3
  35. data/lib/scout/llm/backends/default.rb +183 -99
  36. data/lib/scout/llm/backends/glm.rb +58 -0
  37. data/lib/scout/llm/backends/huggingface.rb +196 -26
  38. data/lib/scout/llm/backends/ollama.rb +13 -1
  39. data/lib/scout/llm/backends/openai.rb +0 -2
  40. data/lib/scout/llm/backends/openwebui.rb +20 -13
  41. data/lib/scout/llm/backends/relay.rb +22 -22
  42. data/lib/scout/llm/backends/responses.rb +1 -1
  43. data/lib/scout/llm/chat/agent_meta.rb +264 -0
  44. data/lib/scout/llm/chat/annotation.rb +39 -10
  45. data/lib/scout/llm/chat/parse.rb +28 -6
  46. data/lib/scout/llm/chat/persist.rb +25 -0
  47. data/lib/scout/llm/chat/process/clear.rb +41 -6
  48. data/lib/scout/llm/chat/process/files.rb +21 -6
  49. data/lib/scout/llm/chat/process/meta.rb +421 -34
  50. data/lib/scout/llm/chat/process/options.rb +21 -1
  51. data/lib/scout/llm/chat/process/tools.rb +56 -15
  52. data/lib/scout/llm/chat/process.rb +4 -0
  53. data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
  54. data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
  55. data/lib/scout/llm/chat/prompt.rb +48 -0
  56. data/lib/scout/llm/chat/provenance.rb +775 -0
  57. data/lib/scout/llm/chat/tool_calls.rb +76 -0
  58. data/lib/scout/llm/chat.rb +18 -2
  59. data/lib/scout/llm/embed.rb +11 -3
  60. data/lib/scout/llm/image.rb +86 -0
  61. data/lib/scout/llm/mcp.rb +10 -2
  62. data/lib/scout/llm/rag.rb +3 -3
  63. data/lib/scout/llm/tools/call.rb +160 -11
  64. data/lib/scout/llm/tools/knowledge_base.rb +1 -1
  65. data/lib/scout/llm/tools/workflow.rb +32 -16
  66. data/lib/scout/model/python/huggingface/causal.rb +23 -5
  67. data/lib/scout/model/python/huggingface.rb +2 -1
  68. data/lib/scout-ai.rb +1 -0
  69. data/python/README.md +197 -14
  70. data/python/scout_ai/huggingface/eval.py +245 -34
  71. data/python/tests/test_huggingface_eval.py +58 -0
  72. data/research/ChatAnalyst-required-changes.md +167 -0
  73. data/research/agent-delegation-analysis.md +810 -0
  74. data/research/agent-meta-provenance-integration-plan.md +622 -0
  75. data/research/agent-workflow-analysis.md +1120 -0
  76. data/research/backends-analysis.md +836 -0
  77. data/research/chat-core-analysis.md +946 -0
  78. data/research/chatanalyst-provenance/00-baseline.md +30 -0
  79. data/research/chatanalyst-provenance/01-repo-map.md +60 -0
  80. data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
  81. data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
  82. data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
  83. data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
  84. data/research/chatanalyst-provenance/07-critic-review.md +25 -0
  85. data/research/chatanalyst-provenance/final-report.md +45 -0
  86. data/research/chatanalyst-provenance/resumption.md +37 -0
  87. data/research/coding-philosophy-analysis.md +928 -0
  88. data/research/commands-analysis.md +947 -0
  89. data/research/multi-agent-patterns-analysis.md +853 -0
  90. data/research/prompt-strategies-analysis.md +630 -0
  91. data/research/prov-verbosity-fix-notes.md +77 -0
  92. data/research/provenance-analysis.md +469 -0
  93. data/research/provenance-navigation-design.md +640 -0
  94. data/research/synthesis-report.md +487 -0
  95. data/research/tools-system-analysis.md +779 -0
  96. data/scout-ai.gemspec +100 -11
  97. data/scout_commands/agent/ask +13 -3
  98. data/scout_commands/agent/kb +2 -0
  99. data/scout_commands/llm/ask +11 -4
  100. data/scout_commands/llm/md +76 -0
  101. data/scout_commands/llm/process_queries +48 -0
  102. data/scout_commands/llm/prov +602 -0
  103. data/scout_commands/llm/word +71 -0
  104. data/scout_commands/workflow/mcp +43 -0
  105. data/share/word/reference.docx +0 -0
  106. data/test/etc/AI/mock.yaml +11 -0
  107. data/test/fixtures/backends/anthropic.json +19 -0
  108. data/test/fixtures/backends/anthropic_tool_use.json +24 -0
  109. data/test/fixtures/backends/bedrock.json +8 -0
  110. data/test/fixtures/backends/bedrock_embedding.json +3 -0
  111. data/test/fixtures/backends/bedrock_tool_use.json +17 -0
  112. data/test/fixtures/backends/ollama.json +16 -0
  113. data/test/fixtures/backends/ollama_tool_call.json +27 -0
  114. data/test/fixtures/backends/openai_chat.json +21 -0
  115. data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
  116. data/test/fixtures/backends/responses.json +33 -0
  117. data/test/fixtures/backends/responses_tool_call.json +28 -0
  118. data/test/integration/README.md +32 -0
  119. data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
  120. data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
  121. data/test/integration/scout/llm/backends/test_relay.rb +52 -0
  122. data/test/integration/scout/llm/test_infrastructure.rb +74 -0
  123. data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
  124. data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
  125. data/test/integration/scout/model/test_base.rb +91 -0
  126. data/test/scout/llm/agent/test_chat.rb +8 -2
  127. data/test/scout/llm/agent/test_save.rb +413 -0
  128. data/test/scout/llm/agent/test_workflow.rb +110 -0
  129. data/test/scout/llm/backends/test_anthropic.rb +93 -10
  130. data/test/scout/llm/backends/test_bedrock.rb +118 -2
  131. data/test/scout/llm/backends/test_huggingface.rb +137 -42
  132. data/test/scout/llm/backends/test_ollama.rb +70 -20
  133. data/test/scout/llm/backends/test_openwebui.rb +42 -40
  134. data/test/scout/llm/backends/test_relay.rb +4 -2
  135. data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
  136. data/test/scout/llm/chat/process/test_meta.rb +518 -0
  137. data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
  138. data/test/scout/llm/chat/test_agent_meta.rb +357 -0
  139. data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
  140. data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
  141. data/test/scout/llm/chat/test_parse.rb +70 -15
  142. data/test/scout/llm/chat/test_prov_cli.rb +274 -0
  143. data/test/scout/llm/chat/test_provenance.rb +240 -0
  144. data/test/scout/llm/chat/test_tool_calls.rb +38 -0
  145. data/test/scout/llm/test_agent.rb +13 -36
  146. data/test/scout/llm/test_ask.rb +75 -52
  147. data/test/scout/llm/test_chat.rb +107 -13
  148. data/test/scout/llm/test_embed.rb +48 -0
  149. data/test/scout/llm/test_rag.rb +23 -16
  150. data/test/scout/llm/test_tools.rb +12 -1
  151. data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
  152. data/test/scout/llm/tools/test_mcp.rb +5 -3
  153. data/test/scout/llm/tools/test_workflow.rb +23 -2
  154. data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
  155. data/test/scout/model/python/huggingface/test_causal.rb +9 -3
  156. data/test/scout/model/python/huggingface/test_classification.rb +11 -2
  157. data/test/scout/model/python/test_torch.rb +2 -0
  158. data/test/scout/model/python/torch/test_helpers.rb +4 -0
  159. data/test/scout/model/test_base.rb +4 -2
  160. data/test/support/availability.rb +231 -0
  161. data/test/support/fake_clients.rb +138 -0
  162. data/test/support/fixtures.rb +21 -0
  163. data/test/support/infrastructure_probes.rb +136 -0
  164. data/test/support/mock_backend.rb +215 -0
  165. data/test/test_helper.rb +32 -2
  166. metadata +99 -10
  167. data/doc/Agent.md +0 -327
  168. data/doc/Chat.md +0 -458
  169. data/doc/LLM.md +0 -340
  170. data/doc/RAG.md +0 -129
  171. data/scout_commands/documenter +0 -148
  172. data/test/scout/llm/backends/test_openai.rb +0 -192
  173. data/test/scout/llm/backends/test_responses.rb +0 -238
  174. data/test/scout/llm/test_parse.rb +0 -98
data/lib/scout-ai.rb CHANGED
@@ -7,6 +7,7 @@ Path.add_path :scout_ai_lib, File.join(Path.caller_lib_dir(__FILE__), "{TOPLEVEL
7
7
  require 'scout/llm/ask'
8
8
  require 'scout/llm/chat'
9
9
  require 'scout/llm/embed'
10
+ require 'scout/llm/image'
10
11
  require 'scout/llm/agent'
11
12
 
12
13
 
data/python/README.md CHANGED
@@ -11,6 +11,11 @@ The package provides a lightweight Python interface to Scout-AI chats and agents
11
11
 
12
12
  In practice, the Python layer builds chat objects, serializes them through the Scout-AI CLI, and delegates execution back to Scout.
13
13
 
14
+ Two complementary Python stories exist in this repository:
15
+
16
+ - `python/README.md` — use Scout-AI chats and agents from Python
17
+ - `doc/PythonAgentTasks.md` — write Python-backed workflow tasks that Ruby agents can load through `PythonWorkflow`
18
+
14
19
  ## Requirements
15
20
 
16
21
  The Python package expects the `scout-ai` command to be available.
@@ -45,7 +50,43 @@ For Hugging Face / RLHF helpers:
45
50
 
46
51
  pip install "scout-ai[huggingface] @ git+https://github.com/mikisvaz/scout-ai.git@main#subdirectory=python"
47
52
 
48
- ## Quick example
53
+ ## Design in one paragraph
54
+
55
+ The Python package is intentionally thin.
56
+
57
+ - `Chat` builds an in-memory list of Scout chat messages.
58
+ - `Agent` wraps a Scout-AI agent with eager `start_chat` / `current_chat` semantics.
59
+ - `ScoutRunner` calls Ruby CLI commands.
60
+ - Serialization is delegated to `scout-ai llm json`.
61
+ - Execution is delegated to `scout-ai llm ask` or `scout-ai agent ask`.
62
+
63
+ That means Python does not reimplement Scout chat parsing or workflow/tool execution.
64
+
65
+ ## Main classes
66
+
67
+ - `scout_ai.Chat` — message builder and plain LLM chat runner
68
+ - `scout_ai.Agent` — thin wrapper over Scout agents with eager `current_chat`
69
+ - `scout_ai.Message` — message wrapper returned by `chat()`
70
+ - `scout_ai.ScoutRunner` — CLI bridge used internally
71
+ - `scout_ai.load_agent(name, ...)` — convenience constructor
72
+
73
+ ## Quick `Chat` example
74
+
75
+ from scout_ai import Chat
76
+
77
+ chat = Chat()
78
+ chat.endpoint("nano")
79
+ chat.system("You are concise")
80
+ chat.user("Say hello")
81
+
82
+ delta = chat.ask()
83
+ print(delta.to_json())
84
+
85
+ last = chat.chat()
86
+ print(last.role)
87
+ print(last.content)
88
+
89
+ ## Quick `Agent` example
49
90
 
50
91
  from scout_ai import load_agent
51
92
 
@@ -56,16 +97,153 @@ For Hugging Face / RLHF helpers:
56
97
  message = agent.chat()
57
98
  print(message.content)
58
99
 
59
- ## Chat example
100
+ A more tool-oriented example:
101
+
102
+ from scout_ai import load_agent
103
+
104
+ agent = load_agent("Planner", endpoint="deep")
105
+ agent.import_("context.chat")
106
+ agent.file("README.md")
107
+ agent.tool("ComputerUse searxng url")
108
+ agent.user("Summarize the file and search for a few references")
109
+
110
+ message = agent.chat()
111
+ print(message.content)
112
+
113
+ ## `ask()` vs `chat()` semantics
114
+
115
+ This distinction is the most important part of the API.
116
+
117
+ ### `Chat.ask()`
118
+
119
+ - serializes the current chat to a temporary file
120
+ - runs `scout-ai llm ask -c <tmp_file>`
121
+ - reloads the resulting chat file
122
+ - computes the delta between the original and updated message lists
123
+ - returns a new `Chat` object containing only the new messages
124
+ - does not mutate the original `Chat`
125
+
126
+ ### `Chat.chat()`
127
+
128
+ - performs the same execution as `ask()`
129
+ - appends the new messages to the current `Chat`
130
+ - returns the last meaningful new message
131
+ - skips a trailing bookkeeping message such as `previous_response_id`
132
+
133
+ ### `Agent.ask()`
134
+
135
+ - uses the agent-aware command `scout-ai agent ask <agent_name> -c <tmp_file>`
136
+ - returns a delta `Chat`
137
+ - does not mutate `current_chat`
138
+
139
+ ### `Agent.chat()`
140
+
141
+ - uses the same agent-aware command
142
+ - appends the new messages to `current_chat`
143
+ - returns the last meaningful new message
144
+
145
+ This mirrors how Scout-AI distinguishes between returning a trace/delta and advancing the active conversation.
146
+
147
+ ## Message builder methods
148
+
149
+ `Chat` exposes a Python-side builder API that mirrors the Ruby chat builder closely.
150
+
151
+ Common methods include:
152
+
153
+ - `user(text)`
154
+ - `system(text)`
155
+ - `assistant(text)`
156
+ - `file(path)`
157
+ - `directory(path)`
158
+ - `image(path)`
159
+ - `pdf(path)`
160
+ - `import_(path)`
161
+ - `import_last(path)`
162
+ - `continue_(path)`
163
+ - `tool(spec)`
164
+ - `use(spec)`
165
+ - `introduce(workflow)`
166
+ - `task(workflow, task_name, **inputs)`
167
+ - `inline_task(...)`
168
+ - `exec_task(...)`
169
+ - `job(step_or_path)`
170
+ - `inline_job(step_or_path)`
171
+ - `association(name, path, **options)`
172
+ - `endpoint(value)`
173
+ - `model(value)`
174
+ - `backend(value)`
175
+ - `format(value)`
176
+ - `option(name, value)`
177
+ - `sticky_option(name, value)`
178
+ - `persist(value=True)`
179
+ - `previous_response_id(value)`
180
+
181
+ Notes:
182
+
183
+ - Python uses `import_()` and `continue_()` because `import` and `continue` are reserved keywords.
184
+ - `use()` is just an alias for `tool()`.
185
+ - endpoint and model settings are represented as messages, just like in Ruby Scout-AI chats.
186
+
187
+ ## Saving and loading chats
188
+
189
+ You can save or load the Python chat wrapper in either Scout chat format or JSON format.
60
190
 
61
191
  from scout_ai import Chat
62
192
 
63
- chat = Chat()
64
- chat.system("You are concise")
65
- chat.user("Say hello")
193
+ chat = Chat().system("You are helpful").user("Summarize this")
194
+ chat.save_chat("example.chat")
195
+ chat.save_json("example.json")
66
196
 
67
- delta = chat.ask()
68
- print(delta.to_json())
197
+ loaded_chat = Chat.load("example.chat", input_format="chat")
198
+ loaded_json = Chat.load("example.json", input_format="json")
199
+
200
+ The rendered Scout chat text is also available directly:
201
+
202
+ text = chat.render()
203
+ print(text)
204
+
205
+ ## How the conversion works
206
+
207
+ The Python package deliberately does not implement Scout chat parsing or printing itself.
208
+
209
+ Instead it always round-trips through:
210
+
211
+ scout-ai llm json
212
+
213
+ That command is used to:
214
+
215
+ - convert JSON messages to Scout chat text
216
+ - convert Scout chat text back to JSON messages
217
+
218
+ This keeps Ruby as the authority for the chat format.
219
+
220
+ ## Eager agent initialization
221
+
222
+ `load_agent(name, ...)` initializes `start_chat` and `current_chat` eagerly.
223
+
224
+ In the current implementation, the Python wrapper tries to resolve the agent path through:
225
+
226
+ scout-ai agent find <agent_name>
227
+
228
+ and then loads the agent's `start_chat` file when it can find one.
229
+
230
+ That works best for agent directories with an explicit `start_chat` file. If an agent's initial state is synthesized indirectly on the Ruby side rather than stored as a file, the Python wrapper may start from a smaller initial chat.
231
+
232
+ ## Relationship to Python-backed workflow tasks
233
+
234
+ The Python package described here is for using chats and agents from Python.
235
+
236
+ It is separate from writing workflow tasks in Python.
237
+
238
+ If you want to create Python functions that Ruby agents can load and use as workflow tasks, see:
239
+
240
+ ../doc/PythonAgentTasks.md
241
+
242
+ That document explains:
243
+
244
+ - `scout.task(...)`
245
+ - `PythonWorkflow`
246
+ - agent directory auto-loading from `python/*.py`
69
247
 
70
248
  ## What pip installs
71
249
 
@@ -77,15 +255,20 @@ That separation is intentional:
77
255
  - Ruby remains responsible for the actual Scout-AI runtime
78
256
  - Python provides an ergonomic interface to that runtime
79
257
 
80
- ## Package layout
81
-
82
- - `scout_ai.chat.Chat` — chat builder and execution wrapper
83
- - `scout_ai.agent.Agent` — thin wrapper over Scout agents with eager `current_chat`
84
- - `scout_ai.runner.ScoutRunner` — CLI bridge used internally
85
- - `scout_ai.message.Message` — message wrapper returned by `chat()`
86
-
87
258
  ## Running tests
88
259
 
89
260
  From the repository root:
90
261
 
91
262
  PYTHONPATH=python python -m unittest discover python/tests
263
+
264
+ A minimal smoke test can also round-trip a chat through the real CLI bridge:
265
+
266
+ PYTHONPATH=python python - <<'PY'
267
+ from scout_ai import Chat, ScoutRunner
268
+
269
+ runner = ScoutRunner(command=['bin/scout-ai'])
270
+ chat = Chat(runner=runner).system('You are concise').user('Hello world')
271
+ text = chat.render()
272
+ print(text)
273
+ print(Chat.from_text(text, runner=runner).to_json())
274
+ PY
@@ -1,10 +1,16 @@
1
+ import json
2
+ import re
3
+
4
+
1
5
  def forward(model, features):
2
6
  return model(**features)
3
7
 
8
+
4
9
  def get_logits(predictions):
5
10
  logits = predictions["logits"]
6
11
  return [v.detach().cpu().numpy() for v in logits]
7
12
 
13
+
8
14
  def eval_model(model, tokenizer, texts, return_logits=True):
9
15
  features = tokenizer(texts, return_tensors='pt', truncation=True).to(model.device)
10
16
  model.eval()
@@ -13,48 +19,253 @@ def eval_model(model, tokenizer, texts, return_logits=True):
13
19
  return get_logits(predictions)
14
20
  return predictions
15
21
 
16
- def eval_causal_lm_chat(
22
+
23
+ def _move_to_device(inputs, device):
24
+ if hasattr(inputs, "to"):
25
+ return inputs.to(device)
26
+
27
+ if isinstance(inputs, dict):
28
+ return {
29
+ key: value.to(device) if hasattr(value, "to") else value
30
+ for key, value in inputs.items()
31
+ }
32
+
33
+ return inputs
34
+
35
+
36
+ def _prepare_tools(tools, tool_argument="tools"):
37
+ if tools is None:
38
+ return None
39
+
40
+ prepared = []
41
+ for tool in tools:
42
+ if isinstance(tool, dict):
43
+ tool = dict(tool)
44
+ function = tool.get("function")
45
+
46
+ if tool_argument != "tools" and isinstance(function, dict):
47
+ prepared.append(function)
48
+ elif tool_argument == "tools" and function is None and "name" in tool and "parameters" in tool:
49
+ prepared.append({"type": "function", "function": tool})
50
+ else:
51
+ prepared.append(tool)
52
+ else:
53
+ prepared.append(tool)
54
+
55
+ return prepared
56
+
57
+
58
+ def _prepare_chat_inputs(
17
59
  model, tokenizer, messages,
60
+ tools=None,
18
61
  chat_template=None,
19
62
  chat_template_kwargs=None,
20
- generation_kwargs=None
63
+ tool_argument=None,
21
64
  ):
22
- """
23
- Evaluate a CausalLM model given chat messages. Uses tokenizer's chat template by default.
24
-
25
- Args:
26
- model: Huggingface CausalLM
27
- tokenizer: Huggingface tokenizer
28
- messages: List[Dict[str, str]] (OpenAI API style, 'role' and 'content')
29
- chat_template: (Optional) Override string for the chat template.
30
- chat_template_kwargs: (Optional) Dict, kwargs for apply_chat_template (like tokenize, add_generation_prompt, etc).
31
- generation_kwargs: (Optional) Dict for model.generate
32
-
33
- Returns:
34
- Generated text (or list, depending on settings).
35
- """
36
- chat_template_kwargs = chat_template_kwargs or {}
37
- generation_kwargs = generation_kwargs or {}
38
-
39
- # If the tokenizer has a chat template (HF 4.34+)
40
- if hasattr(tokenizer, "___apply_chat_template"):
41
- kwargs = dict(add_generation_prompt=True, tokenize=False)
65
+ chat_template_kwargs = dict(chat_template_kwargs or {})
66
+ tool_argument = tool_argument or "tools"
67
+
68
+ if hasattr(tokenizer, "apply_chat_template"):
69
+ kwargs = dict(
70
+ add_generation_prompt=True,
71
+ tokenize=True,
72
+ return_dict=True,
73
+ return_tensors="pt",
74
+ )
42
75
  kwargs.update(chat_template_kwargs)
76
+
43
77
  if chat_template is not None:
44
- # Override the template (may require tokenizer._chat_template)
45
- tokenizer._chat_template = chat_template
46
- prompt = tokenizer.apply_chat_template(messages, **kwargs)
78
+ kwargs["chat_template"] = chat_template
79
+
80
+ prepared_tools = _prepare_tools(tools, tool_argument)
81
+ if prepared_tools:
82
+ kwargs[tool_argument] = prepared_tools
83
+
84
+ rendered = tokenizer.apply_chat_template(messages, **kwargs)
85
+
86
+ if isinstance(rendered, str):
87
+ inputs = tokenizer(rendered, return_tensors="pt")
88
+ else:
89
+ inputs = rendered
47
90
  else:
48
- # Fallback: simple concatenation
49
- prompt = "\n".join([msg['content'] for msg in messages])
91
+ prompt = "\n".join(str(message.get("content", "")) for message in messages)
92
+ inputs = tokenizer(prompt, return_tensors="pt")
93
+
94
+ return _move_to_device(inputs, model.device)
95
+
96
+
97
+ def _decode_generated_text(tokenizer, inputs, output_ids):
98
+ input_ids = inputs["input_ids"]
99
+ return tokenizer.decode(
100
+ output_ids[0, input_ids.shape[1]:],
101
+ skip_special_tokens=True,
102
+ )
103
+
104
+
105
+ def _normalize_tool_call(tool_call, index=0):
106
+ if tool_call is None:
107
+ return None
108
+
109
+ if "function" in tool_call and isinstance(tool_call["function"], dict):
110
+ function = dict(tool_call["function"])
111
+ else:
112
+ function = {
113
+ "name": tool_call.get("name"),
114
+ "arguments": tool_call.get("arguments", tool_call.get("parameters", {})),
115
+ }
116
+
117
+ arguments = function.get("arguments", {})
118
+ if isinstance(arguments, str):
119
+ try:
120
+ arguments = json.loads(arguments)
121
+ except Exception:
122
+ pass
123
+
124
+ return {
125
+ "id": tool_call.get("id") or tool_call.get("call_id") or f"call_{index}",
126
+ "type": "function",
127
+ "function": {
128
+ "name": function.get("name"),
129
+ "arguments": arguments,
130
+ },
131
+ }
132
+
133
+
134
+ def _normalize_response(parsed, raw_text=None):
135
+ if parsed is None:
136
+ return {"role": "assistant", "content": (raw_text or "").strip()}
137
+
138
+ if isinstance(parsed, str):
139
+ return {"role": "assistant", "content": parsed.strip()}
140
+
141
+ if isinstance(parsed, list):
142
+ return {
143
+ "role": "assistant",
144
+ "content": "",
145
+ "tool_calls": [
146
+ tool_call
147
+ for tool_call in (
148
+ _normalize_tool_call(tool_call, index)
149
+ for index, tool_call in enumerate(parsed)
150
+ )
151
+ if tool_call is not None
152
+ ],
153
+ }
154
+
155
+ message = dict(parsed)
156
+ message.setdefault("role", "assistant")
157
+
158
+ if "tool_calls" in message:
159
+ message["tool_calls"] = [
160
+ tool_call
161
+ for tool_call in (
162
+ _normalize_tool_call(tool_call, index)
163
+ for index, tool_call in enumerate(message.get("tool_calls", []))
164
+ )
165
+ if tool_call is not None
166
+ ]
167
+ elif "name" in message and ("arguments" in message or "parameters" in message):
168
+ message["tool_calls"] = [_normalize_tool_call(message, 0)]
169
+ message.setdefault("content", "")
170
+
171
+ if message.get("content") is None:
172
+ message["content"] = ""
173
+
174
+ if raw_text is not None and "content" not in message:
175
+ message["content"] = raw_text.strip()
176
+
177
+ return message
178
+
179
+
180
+ def _parse_tool_call_blocks(output_text):
181
+ matches = re.findall(r"<tool_call>\s*(.*?)\s*</tool_call>", output_text, re.DOTALL)
182
+ if not matches:
183
+ return None
184
+
185
+ tool_calls = []
186
+ for match in matches:
187
+ try:
188
+ payload = json.loads(match)
189
+ except Exception:
190
+ continue
191
+
192
+ payloads = payload if isinstance(payload, list) else [payload]
193
+ for item in payloads:
194
+ normalized = _normalize_tool_call(item, len(tool_calls))
195
+ if normalized is not None:
196
+ tool_calls.append(normalized)
197
+
198
+ if not tool_calls:
199
+ return None
200
+
201
+ content = re.sub(r"<tool_call>\s*.*?\s*</tool_call>", "", output_text, flags=re.DOTALL).strip()
202
+ return {
203
+ "role": "assistant",
204
+ "content": content,
205
+ "tool_calls": tool_calls,
206
+ }
207
+
208
+
209
+ def parse_causal_lm_response(tokenizer, output_text, response_parser=None):
210
+ if response_parser not in (False, "false", "none"):
211
+ if hasattr(tokenizer, "parse_response"):
212
+ try:
213
+ parsed = tokenizer.parse_response(output_text)
214
+ normalized = _normalize_response(parsed, raw_text=output_text)
215
+ if normalized.get("tool_calls") or normalized.get("content") or normalized.get("thinking"):
216
+ return normalized
217
+ except Exception:
218
+ pass
219
+
220
+ parsed = _parse_tool_call_blocks(output_text)
221
+ if parsed is not None:
222
+ return parsed
223
+
224
+ return {"role": "assistant", "content": output_text.strip()}
225
+
226
+
227
+ def eval_causal_lm_chat(
228
+ model, tokenizer, messages,
229
+ chat_template=None,
230
+ chat_template_kwargs=None,
231
+ generation_kwargs=None,
232
+ tool_argument=None,
233
+ ):
234
+ generation_kwargs = dict(generation_kwargs or {})
235
+
236
+ inputs = _prepare_chat_inputs(
237
+ model, tokenizer, messages,
238
+ chat_template=chat_template,
239
+ chat_template_kwargs=chat_template_kwargs,
240
+ tool_argument=tool_argument,
241
+ )
50
242
 
51
- # Tokenize as usual
52
- inputs = tokenizer(prompt, return_tensors='pt').to(model.device)
53
243
  model.eval()
54
- # Use generate
55
244
  output_ids = model.generate(**inputs, **generation_kwargs)
56
- # Decode only the newly generated tokens (not the prompt)
57
- output_text = tokenizer.decode(
58
- output_ids[0, inputs["input_ids"].shape[1]:], skip_special_tokens=True
245
+ return _decode_generated_text(tokenizer, inputs, output_ids)
246
+
247
+
248
+ def eval_causal_lm_response(
249
+ model, tokenizer, messages,
250
+ tools=None,
251
+ chat_template=None,
252
+ chat_template_kwargs=None,
253
+ generation_kwargs=None,
254
+ tool_argument=None,
255
+ response_parser=None,
256
+ ):
257
+ generation_kwargs = dict(generation_kwargs or {})
258
+
259
+ chat_template = None
260
+ inputs = _prepare_chat_inputs(
261
+ model, tokenizer, messages,
262
+ tools=tools,
263
+ chat_template=chat_template,
264
+ chat_template_kwargs=chat_template_kwargs,
265
+ tool_argument=tool_argument,
59
266
  )
60
- return output_text
267
+
268
+ model.eval()
269
+ output_ids = model.generate(**inputs, **generation_kwargs)
270
+ output_text = _decode_generated_text(tokenizer, inputs, output_ids)
271
+ return parse_causal_lm_response(tokenizer, output_text, response_parser=response_parser)
@@ -0,0 +1,58 @@
1
+ import unittest
2
+
3
+ from scout_ai.huggingface.eval import parse_causal_lm_response
4
+
5
+
6
+ class DummyTokenizer:
7
+ pass
8
+
9
+
10
+ class ParserTokenizer:
11
+ def parse_response(self, text):
12
+ return {
13
+ "role": "assistant",
14
+ "content": "",
15
+ "tool_calls": [
16
+ {
17
+ "type": "function",
18
+ "function": {
19
+ "name": "multiply",
20
+ "arguments": {"a": 3, "b": 4},
21
+ },
22
+ "id": "call_1",
23
+ }
24
+ ],
25
+ }
26
+
27
+
28
+ class HuggingfaceEvalTest(unittest.TestCase):
29
+ def test_parse_xml_tool_call_blocks(self):
30
+ tokenizer = DummyTokenizer()
31
+ text = '<tool_call>{"name": "multiply", "arguments": {"a": 3, "b": 4}}</tool_call>'
32
+
33
+ parsed = parse_causal_lm_response(tokenizer, text)
34
+
35
+ self.assertEqual(parsed["role"], "assistant")
36
+ self.assertEqual(parsed["tool_calls"][0]["function"]["name"], "multiply")
37
+ self.assertEqual(parsed["tool_calls"][0]["function"]["arguments"]["a"], 3)
38
+
39
+ def test_parse_tokenizer_response(self):
40
+ tokenizer = ParserTokenizer()
41
+ text = "ignored"
42
+
43
+ parsed = parse_causal_lm_response(tokenizer, text)
44
+
45
+ self.assertEqual(parsed["tool_calls"][0]["id"], "call_1")
46
+ self.assertEqual(parsed["tool_calls"][0]["function"]["arguments"]["b"], 4)
47
+
48
+ def test_parse_plain_text_when_disabled(self):
49
+ tokenizer = ParserTokenizer()
50
+ text = '<tool_call>{"name": "multiply", "arguments": {"a": 3, "b": 4}}</tool_call>'
51
+
52
+ parsed = parse_causal_lm_response(tokenizer, text, response_parser="none")
53
+
54
+ self.assertEqual(parsed, {"role": "assistant", "content": text})
55
+
56
+
57
+ if __name__ == "__main__":
58
+ unittest.main()