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
data/lib/scout-ai.rb
CHANGED
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
|
-
##
|
|
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
|
-
|
|
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.
|
|
65
|
-
chat.
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
+
tool_argument=None,
|
|
21
64
|
):
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
prompt =
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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()
|