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,946 @@
|
|
|
1
|
+
> **Disclaimer:** This is an architectural investigation, not normative
|
|
2
|
+
> documentation. It was produced during a documentation-revamp effort and may
|
|
3
|
+
> be outdated relative to the current codebase. Treat it as supporting
|
|
4
|
+
> reference material. For maintained documentation, see
|
|
5
|
+
> [../../doc/](../../doc/).
|
|
6
|
+
>
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Chat Core: Data Model, Roles, Persistence, and Processing Pipeline
|
|
10
|
+
|
|
11
|
+
This artifact documents the Chat subsystem in `lib/scout/llm/chat.rb` and its supporting files. It covers the class structure, message schema, roles, parsing, persistence, processing pipeline, and provenance annotations.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Chat Class Structure
|
|
16
|
+
|
|
17
|
+
### What Chat is
|
|
18
|
+
|
|
19
|
+
`Chat` is **not a standalone class**. It is a **Ruby module** — `module Chat` — that appears in multiple files via Ruby's open-module mechanism:
|
|
20
|
+
|
|
21
|
+
- `lib/scout/llm/chat/annotation.rb` — `module Chat` (270 lines)
|
|
22
|
+
- `lib/scout/llm/chat/parse.rb` — `module Chat` (190 lines)
|
|
23
|
+
- `lib/scout/llm/chat/process.rb` — `module Chat` (17 lines)
|
|
24
|
+
- `lib/scout/llm/chat/process/*.rb` — each file opens `module Chat` and adds class methods
|
|
25
|
+
|
|
26
|
+
### The Annotation pattern (Array extension)
|
|
27
|
+
|
|
28
|
+
The key to understanding Chat is the **`extend Annotation`** line at the top of `annotation.rb`:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
module Chat
|
|
32
|
+
extend Annotation
|
|
33
|
+
# ...
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`Annotation` is a mixin from `scout-essentials` (`lib/scout/annotation.rb`). When a module or class calls `extend Annotation`:
|
|
38
|
+
|
|
39
|
+
1. It includes `Annotation::AnnotatedObject` — provides `annotation_types`, `annotation_hash`, `purge`, `annotate`, etc.
|
|
40
|
+
2. It extends `Annotation::AnnotationModule` — provides the `.setup`, `.annotation`, and `.annotations` class methods.
|
|
41
|
+
3. When `Chat.setup(some_array)` is called, it **extends the Array instance with the `Chat` module**, so the Array gains all of Chat's instance methods (defined in `annotation.rb`) while remaining a plain Array.
|
|
42
|
+
|
|
43
|
+
This means:
|
|
44
|
+
- **A Chat IS an Array** of message hashes.
|
|
45
|
+
- All standard Array methods (`each`, `map`, `select`, `<<`, `push`, `reverse`, etc.) work on a Chat.
|
|
46
|
+
- Additional methods like `user()`, `assistant()`, `import()`, `ask()`, `save()`, etc. are available as instance methods.
|
|
47
|
+
- Calling `Chat.setup(messages)` annotates an Array, turning it into a Chat.
|
|
48
|
+
|
|
49
|
+
### The `LLM` module as factory
|
|
50
|
+
|
|
51
|
+
The top-level `LLM` module (in `chat.rb`) serves as the **factory/entry point**:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
module LLM
|
|
55
|
+
def self.chat(file = [], original = nil)
|
|
56
|
+
# ... parses, imports, clears, configures, processes files/tools
|
|
57
|
+
Chat.setup messages
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.messages(question, role = nil)
|
|
61
|
+
# converts a string, Array of strings, or parsed messages
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Key factory methods on `LLM`:
|
|
67
|
+
| Method | Purpose |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `LLM.chat(file_or_messages, original=nil)` | Main entry: parse → process → return Chat |
|
|
70
|
+
| `LLM.messages(question, role=nil)` | Convert string/Array into message hashes |
|
|
71
|
+
| `LLM.print(chat)` | Serialize a Chat back to text |
|
|
72
|
+
| `LLM.options(...)` / `LLM.tools(...)` / `LLM.associations(...)` | Delegate to Chat module methods |
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Message Hash Schema
|
|
77
|
+
|
|
78
|
+
Every message in a Chat is a **Hash** with two mandatory keys:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
{ role: String, content: String|Hash|Array|nil }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Key: `:role` (String)
|
|
85
|
+
|
|
86
|
+
Always a string identifying the message type. See the Roles section below for the full list.
|
|
87
|
+
|
|
88
|
+
### Key: `:content` (String, Hash, Array, or nil)
|
|
89
|
+
|
|
90
|
+
The message body. The type varies:
|
|
91
|
+
- **String** — most common; the text content of the message.
|
|
92
|
+
- **Hash or Array** — used for structured content (e.g., multimodal/image data). When serialized via `Chat.print`, Hash/Array content is emitted as JSON.
|
|
93
|
+
- **nil or ''** — some messages (like `system:` with no inline content) have empty content, serving as markers.
|
|
94
|
+
|
|
95
|
+
### IndifferentHash
|
|
96
|
+
|
|
97
|
+
All message hashes are processed with `IndiferentHash.setup`, meaning keys can be accessed as either symbols (`msg[:role]`) or strings (`msg["role"]`):
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
def self.indiferent(messages)
|
|
101
|
+
messages.collect{|msg| IndiferentHash.setup msg }
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Metadata fields on messages
|
|
106
|
+
|
|
107
|
+
Messages do **not** have a separate `metadata` field. Instead, metadata is encoded **within the content** of special-role messages:
|
|
108
|
+
|
|
109
|
+
- **`meta:` messages** — content is a serialized key=value string (parsed by `Chat.parse_meta`). Example: `meta: job=/path/to/job pt_c=1000 ct_c=500`
|
|
110
|
+
- **`option:` messages** — content is `"key value"` pairs (parsed by partitioning on space).
|
|
111
|
+
- **`config:` messages** — content is space-separated `key value tokens...`
|
|
112
|
+
|
|
113
|
+
### Tool calls and tool results in messages
|
|
114
|
+
|
|
115
|
+
Tool calls and results use two special roles with **JSON content**:
|
|
116
|
+
|
|
117
|
+
**Tool call (function_call):**
|
|
118
|
+
```ruby
|
|
119
|
+
{
|
|
120
|
+
role: 'function_call',
|
|
121
|
+
content: '{"name":"task_name","arguments":{...},"id":"some-id"}'
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Tool result (function_call_output):**
|
|
126
|
+
```ruby
|
|
127
|
+
{
|
|
128
|
+
role: 'function_call_output',
|
|
129
|
+
content: '{"id":"some-id","content":"result text or data"}'
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
These are generated in `Chat.jobs` (tools.rb, lines 69–116) when a `job:` or `inline_job:` message is processed:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
tool_call = {
|
|
137
|
+
name: function_name,
|
|
138
|
+
arguments: step.provided_inputs,
|
|
139
|
+
id: id,
|
|
140
|
+
}
|
|
141
|
+
tool_output = {
|
|
142
|
+
id: id,
|
|
143
|
+
content: content # the job result, loaded from disk
|
|
144
|
+
}
|
|
145
|
+
# Produces two messages:
|
|
146
|
+
[{role: 'function_call', content: tool_call.to_json},
|
|
147
|
+
{role: 'function_call_output', content: tool_output.to_json}]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Roles
|
|
153
|
+
|
|
154
|
+
### Complete Role Catalog
|
|
155
|
+
|
|
156
|
+
The system supports a large number of roles. They fall into several categories:
|
|
157
|
+
|
|
158
|
+
#### Standard conversational roles
|
|
159
|
+
| Role | Semantics |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `system` | System instructions/configuration for the model |
|
|
162
|
+
| `user` | User input / prompts |
|
|
163
|
+
| `assistant` | Model responses. Also triggers option reset in the options processor. |
|
|
164
|
+
|
|
165
|
+
#### Tool execution roles
|
|
166
|
+
| Role | Semantics |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `function_call` | A tool/function invocation. Content is JSON with `name`, `arguments`, `id`. |
|
|
169
|
+
| `function_call_output` | The result of a function call. Content is JSON with `id`, `content`. |
|
|
170
|
+
| `tool` | Declares available workflow tools. Format: `"WorkflowName task_name [inputs]"` |
|
|
171
|
+
| `introduce` | Introduces a workflow's documentation to the model (auto-generates a user message with workflow docs) |
|
|
172
|
+
| `mcp` | Declares MCP (Model Context Protocol) tools. Format: `"url [tool1 tool2 ...]"` or `"stdio command [tools]"` |
|
|
173
|
+
| `kb` | Declares knowledge base tools. Format: `"kb_name [database1 database2]"` |
|
|
174
|
+
| `association` | Registers a knowledge base association as a tool |
|
|
175
|
+
| `clear_tools` | Clears all accumulated tool definitions. Content `false` means don't clear. |
|
|
176
|
+
|
|
177
|
+
#### Job/Task roles
|
|
178
|
+
| Role | Semantics |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `task` | Schedules a workflow job; result loaded asynchronously via `Workflow.produce`. Replaced with `job:` message. |
|
|
181
|
+
| `inline_task` | Schedules a job; replaced with `inline_job:` message (inline content). |
|
|
182
|
+
| `exec_task` | Executes a job immediately inline; result placed as a `user:` message. |
|
|
183
|
+
| `job` | References a completed Step; resolved into `function_call` + `function_call_output` pair. |
|
|
184
|
+
| `inline_job` | References a Step as inline file content; resolved into `file:` message. |
|
|
185
|
+
|
|
186
|
+
#### File/resource roles
|
|
187
|
+
| Role | Semantics |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `file` | Reads a file from disk and wraps content in `<file name="...">` tags as a `user:` message |
|
|
190
|
+
| `directory` | Reads all files in a directory recursively, each as a `file:` message |
|
|
191
|
+
| `pdf` | References a PDF file path (resolved but content handled by backend) |
|
|
192
|
+
| `image` | References an image file path (handled by backend for multimodal) |
|
|
193
|
+
|
|
194
|
+
#### Import/composition roles
|
|
195
|
+
| Role | Semantics |
|
|
196
|
+
|---|---|
|
|
197
|
+
| `import` | Imports another chat file, fully resolved and expanded inline |
|
|
198
|
+
| `continue` | Imports only the last non-empty message from another chat file |
|
|
199
|
+
| `last` | Imports the last message after purging `previous_response_id` messages |
|
|
200
|
+
| `step` | Loads a specific Step from the job referenced in meta; produces an assistant message with the step's answer |
|
|
201
|
+
|
|
202
|
+
#### Configuration/control roles
|
|
203
|
+
| Role | Semantics |
|
|
204
|
+
|---|---|
|
|
205
|
+
| `option` | Sets a per-request option (key value). Resets after assistant reply. |
|
|
206
|
+
| `sticky_option` | Sets an option that persists across assistant replies. |
|
|
207
|
+
| `endpoint` | Sets the LLM endpoint (sticky) |
|
|
208
|
+
| `model` | Sets the model name (sticky) |
|
|
209
|
+
| `backend` | Sets the backend type (sticky) |
|
|
210
|
+
| `agent` | Sets the agent name (sticky) |
|
|
211
|
+
| `persist` | Controls persistence behavior |
|
|
212
|
+
| `previous_response_id` | OpenAI Responses API continuation token (sticky) |
|
|
213
|
+
| `format` | Response format specification (JSON schema). Can be a filename to load from disk. |
|
|
214
|
+
| `config` | Sets Scout::Config values at runtime |
|
|
215
|
+
| `meta` | Internal bookkeeping metadata (token counts, job paths). Not sent to provider. |
|
|
216
|
+
|
|
217
|
+
#### Chat manipulation roles
|
|
218
|
+
| Role | Semantics |
|
|
219
|
+
|---|---|
|
|
220
|
+
| `clear` | Acts as a cut point: everything before the last `clear:` is discarded |
|
|
221
|
+
| `clear_role` / `clean_role` | Marks roles to be cleaned/removed during processing |
|
|
222
|
+
| `clean_role` | Used in `Chat.clean` to remove specific role messages |
|
|
223
|
+
|
|
224
|
+
#### Special parsing roles
|
|
225
|
+
| Role | Semantics |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `agent` | In the parser, an `agent:` header sets the next block role to `user` |
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Parsing (parse.rb)
|
|
232
|
+
|
|
233
|
+
### Overview
|
|
234
|
+
|
|
235
|
+
`Chat.parse(text, role = nil)` converts a text string into an Array of message hashes. It is a **line-oriented parser** with support for:
|
|
236
|
+
|
|
237
|
+
1. **Role headers** — `role: content` lines
|
|
238
|
+
2. **Protected blocks** — code fences, `[[...]]`, XML tags, command output markers
|
|
239
|
+
3. **Escaped role headers** — `\role:` lines that are kept as literal content
|
|
240
|
+
|
|
241
|
+
### Role header parsing
|
|
242
|
+
|
|
243
|
+
Each line is matched against `/^([a-z0-9_]+):(.*)$/`:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
if line =~ /^([a-z0-9_]+):(.*)$/
|
|
247
|
+
role = $1
|
|
248
|
+
inline_content = $2.strip
|
|
249
|
+
# ...
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- **Block form**: `role:` with no inline content → sets current_role, accumulates subsequent lines as content
|
|
254
|
+
- **Inline form**: `role: some text` → immediately creates a message with that text
|
|
255
|
+
|
|
256
|
+
Special handling for `previous_response_id` and `agent` roles: after an inline message, the next block defaults to `user`:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
current_role = 'user' if role == 'previous_response_id'
|
|
260
|
+
current_role = 'user' if role == 'agent'
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Escaped headers
|
|
264
|
+
|
|
265
|
+
A line like `\user: this is literal` (matching `/^\\([a-z0-9_]+):(.*)$/`) is treated as literal content, not a role header. This allows embedding role-like patterns in content.
|
|
266
|
+
|
|
267
|
+
### Protected block types
|
|
268
|
+
|
|
269
|
+
The parser protects certain blocks from being interpreted as role headers:
|
|
270
|
+
|
|
271
|
+
1. **Triple-backtick fences** (``` ... ```): Content between fences is preserved literally.
|
|
272
|
+
2. **`[[ ... ]]` blocks**: Square-bracket delimited protected regions.
|
|
273
|
+
3. **XML tags**: If a line matches `<tagname ...>` and the text contains a matching `</tagname>`, the parser enters XML protected mode with a stack-based tag matcher.
|
|
274
|
+
4. **Command output markers**: Lines matching `something:-- cmd {{{` ... `something:-- cmd }}}` are converted to `<cmd_output cmd="cmd">` ... `</cmd_output>`.
|
|
275
|
+
|
|
276
|
+
### `parse_json` helper
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
def self.parse_json(text)
|
|
280
|
+
re = /.*\`\`\`json\n(.*)\`\`\`\n?.*/sm
|
|
281
|
+
text = text.gsub(re, '\1') if text.include?('```json')
|
|
282
|
+
JSON.parse text
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Strips markdown JSON code fences and parses the inner JSON.
|
|
287
|
+
|
|
288
|
+
### Serialization: `Chat.print(chat)`
|
|
289
|
+
|
|
290
|
+
The inverse of parse — converts an Array of messages back to text:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
def self.print(chat)
|
|
294
|
+
"\n" + chat.collect do |message|
|
|
295
|
+
IndiferentHash.setup message
|
|
296
|
+
case message[:content]
|
|
297
|
+
when Hash, Array
|
|
298
|
+
message[:role].to_s + ":\n\n" + message[:content].to_json
|
|
299
|
+
when nil, ''
|
|
300
|
+
message[:role].to_s + ":"
|
|
301
|
+
else
|
|
302
|
+
if %w(option previous_response_id function_call function_call_output meta).include? message[:role].to_s
|
|
303
|
+
message[:role].to_s + ": " + message[:content].to_s # inline form for these roles
|
|
304
|
+
else
|
|
305
|
+
message[:role].to_s + ":\n\n" +
|
|
306
|
+
message[:content].to_s.gsub(re, '\\\\\1\2') # block form, escaped
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end * "\n\n"
|
|
310
|
+
end
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Key serialization rules:
|
|
314
|
+
- Hash/Array content → JSON in block form
|
|
315
|
+
- nil/empty content → just `role:`
|
|
316
|
+
- `option`, `previous_response_id`, `function_call`, `function_call_output`, `meta` → **inline form** (`role: content`)
|
|
317
|
+
- Everything else → **block form** (`role:\n\ncontent`) with role-like patterns escaped with `\`
|
|
318
|
+
|
|
319
|
+
### `Chat.print_brief(chat, expand = [])`
|
|
320
|
+
|
|
321
|
+
A compact serialization using `Log.fingerprint` for non-expanded roles (produces short hash-like summaries instead of full content).
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Persistence (persist.rb)
|
|
326
|
+
|
|
327
|
+
### Persist driver registration
|
|
328
|
+
|
|
329
|
+
Chat persistence is registered with Scout's `Persist` system via custom save/load drivers:
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
Persist.save_drivers[:chat] = proc do |file, content|
|
|
333
|
+
case content
|
|
334
|
+
when LLM::Agent
|
|
335
|
+
new_chat = content.current_chat - content.start_chat
|
|
336
|
+
Open.sensible_write(file, LLM.print(new_chat))
|
|
337
|
+
when Array
|
|
338
|
+
Open.sensible_write(file, LLM.print(content))
|
|
339
|
+
else
|
|
340
|
+
# handles streams
|
|
341
|
+
stream = content.respond_to?(:stream) ? content.stream :
|
|
342
|
+
content.respond_to?(:dumper_stream) ? content.dumper_stream :
|
|
343
|
+
content
|
|
344
|
+
Open.sensible_write(file, stream)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
Persist.load_drivers[:chat] = proc do |file|
|
|
349
|
+
String === file ? LLM.chat(file) : file
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
Workflow::TYPE_EXTENSIONS[:chat] = :chat
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### File format
|
|
356
|
+
|
|
357
|
+
Chats are persisted as **plain text files** using `Chat.print` format — the same text format that `Chat.parse` reads. This creates a **round-trippable** text format.
|
|
358
|
+
|
|
359
|
+
### How saving works on Chat instances
|
|
360
|
+
|
|
361
|
+
The annotation module provides `save`, `write`, and `write_answer` methods:
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
def save(path, force = true)
|
|
365
|
+
path = path.to_s if Symbol === path
|
|
366
|
+
if not (Open.exists?(path) || Path === path || Path.located?(path))
|
|
367
|
+
path = Scout.chats.find[path] # default to chats directory
|
|
368
|
+
end
|
|
369
|
+
return if Open.exists?(path) && ! force
|
|
370
|
+
Open.write path, LLM.print(self)
|
|
371
|
+
end
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
- `save(path)` — writes full chat using `LLM.print(self)`
|
|
375
|
+
- `write(path)` — writes full chat using `self.print` (processed form)
|
|
376
|
+
- `write_answer(path)` — writes only the final answer text
|
|
377
|
+
|
|
378
|
+
### Loading without re-processing
|
|
379
|
+
|
|
380
|
+
`Chat.load(file)` reads a chat file **without** compiling it (no task execution, file reads, or import resolution):
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
def self.load(file)
|
|
384
|
+
Chat.setup(LLM.messages(Open.read(file.to_s)))
|
|
385
|
+
end
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
This is used for provenance inspection where side effects must be avoided.
|
|
389
|
+
|
|
390
|
+
### Integration with Workflow
|
|
391
|
+
|
|
392
|
+
`Workflow::TYPE_EXTENSIONS[:chat] = :chat` registers `:chat` as a workflow task type extension, allowing Scout workflow tasks to declare `:chat` as their output type and get automatic serialization.
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## Processing Pipeline
|
|
397
|
+
|
|
398
|
+
### Entry point: `LLM.chat`
|
|
399
|
+
|
|
400
|
+
The full processing pipeline is orchestrated in `LLM.chat`:
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
def self.chat(file = [], original = nil)
|
|
404
|
+
original ||= (String === file and Open.exists?(file)) ? file : Path.setup($0.dup)
|
|
405
|
+
caller_lib_dir = Path.caller_lib_dir(nil, 'chats')
|
|
406
|
+
|
|
407
|
+
# 1. Parse into messages
|
|
408
|
+
if Path.is_filename? file
|
|
409
|
+
messages = self.messages Open.read(file), file
|
|
410
|
+
else
|
|
411
|
+
messages = self.messages file
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# 2. Make all hashes IndiferentHash
|
|
415
|
+
messages = Chat.indiferent messages
|
|
416
|
+
|
|
417
|
+
# 3. Resolve imports/continues/lasts
|
|
418
|
+
messages = Chat.imports messages, original, caller_lib_dir
|
|
419
|
+
|
|
420
|
+
# 4. Apply clear/clean directives
|
|
421
|
+
messages = Chat.clear messages
|
|
422
|
+
messages = Chat.clean messages
|
|
423
|
+
|
|
424
|
+
# 5. Apply config options
|
|
425
|
+
messages = Chat.config messages
|
|
426
|
+
|
|
427
|
+
# 6. Execute tasks and schedule jobs
|
|
428
|
+
messages = Chat.tasks messages
|
|
429
|
+
messages = Chat.jobs messages
|
|
430
|
+
|
|
431
|
+
# 7. Resolve file/directory references
|
|
432
|
+
messages = Chat.files messages, original, caller_lib_dir
|
|
433
|
+
|
|
434
|
+
# 8. Annotate as Chat
|
|
435
|
+
Chat.setup messages
|
|
436
|
+
end
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Pipeline stages (in order)
|
|
440
|
+
|
|
441
|
+
#### 1. Parse → Messages
|
|
442
|
+
Converts text input to `[{role:, content:}, ...]` via `LLM.messages`/`Chat.parse`.
|
|
443
|
+
|
|
444
|
+
#### 2. `Chat.indiferent`
|
|
445
|
+
Applies `IndiferentHash.setup` to every message hash.
|
|
446
|
+
|
|
447
|
+
#### 3. `Chat.imports` (files.rb)
|
|
448
|
+
Resolves `import:`, `continue:`, and `last:` messages:
|
|
449
|
+
- **`import:`** — recursively calls `LLM.chat` on the referenced file, fully expanding it
|
|
450
|
+
- **`continue:`** — imports only the last non-empty message from the referenced file
|
|
451
|
+
- **`last:`** — imports the last message after purging `previous_response_id` messages
|
|
452
|
+
- Uses `Chat.find_file` to resolve paths across multiple search locations (relative to original, caller lib dir, Scout.chats, absolute)
|
|
453
|
+
|
|
454
|
+
#### 4. `Chat.clear` (clear.rb)
|
|
455
|
+
Implements chat truncation:
|
|
456
|
+
- Walks messages in **reverse** from the end
|
|
457
|
+
- Stops at the last `clear:` message (everything before it is discarded)
|
|
458
|
+
- Respects `clear_tools:` (controls whether function_call/function_call_output messages survive)
|
|
459
|
+
- Processes `clean_role:` / `clear_role:` markers to remove specific role messages
|
|
460
|
+
|
|
461
|
+
```ruby
|
|
462
|
+
def self.clear(messages, role = 'clear')
|
|
463
|
+
new = []
|
|
464
|
+
clear_tools = false
|
|
465
|
+
clean_roles = []
|
|
466
|
+
messages.reverse.each do |message|
|
|
467
|
+
if message[:role].to_s == role.to_s
|
|
468
|
+
break # everything before this point is discarded
|
|
469
|
+
elsif message[:role].to_s == 'clear_tools'
|
|
470
|
+
clear_tools = message['content'].to_s != 'false'
|
|
471
|
+
elsif ['function_call', 'function_call_output'].include?(message[:role].to_s)
|
|
472
|
+
new << message unless clear_tools
|
|
473
|
+
elsif ['clean_role', 'clear_role'].include?(message[:role].to_s)
|
|
474
|
+
clean_roles << message[:content].strip
|
|
475
|
+
else
|
|
476
|
+
new << message
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
new = Chat.setup new.reverse
|
|
480
|
+
clean_roles.each { |role| new = self.clean(new, role) }
|
|
481
|
+
new
|
|
482
|
+
end
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
#### 5. `Chat.clean` (clear.rb)
|
|
486
|
+
Removes messages with empty content and optionally messages of specified roles:
|
|
487
|
+
|
|
488
|
+
```ruby
|
|
489
|
+
def self.clean(messages, role = ['skip', 'previous_response_id'])
|
|
490
|
+
messages.reject do |message|
|
|
491
|
+
((String === message[:content]) && message[:content].empty?) ||
|
|
492
|
+
(Array === role ? false : message[:role].to_s == role.to_s)
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
#### 6. `Chat.config` (options.rb)
|
|
498
|
+
Processes `config:` messages to set `Scout::Config` values at runtime:
|
|
499
|
+
|
|
500
|
+
```ruby
|
|
501
|
+
def self.config(chat)
|
|
502
|
+
new = []
|
|
503
|
+
chat.select do |info|
|
|
504
|
+
if info[:role].to_s == 'config'
|
|
505
|
+
key, value, *tokens = info[:content].split(" ")
|
|
506
|
+
Scout::Config.set({key => value}, *tokens)
|
|
507
|
+
next # removed from chat
|
|
508
|
+
end
|
|
509
|
+
new << info
|
|
510
|
+
end
|
|
511
|
+
chat.replace new
|
|
512
|
+
end
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
#### 7. `Chat.tasks` (tools.rb)
|
|
516
|
+
Processes `task:`, `inline_task:`, and `exec_task:` messages:
|
|
517
|
+
- Parses workflow name, task name, and options from content
|
|
518
|
+
- Creates workflow jobs via `Chat.load_workflow`
|
|
519
|
+
- `exec_task:` — executes immediately, result becomes a `user:` message
|
|
520
|
+
- `inline_task:` — replaced with `inline_job:` message (for later file processing)
|
|
521
|
+
- `task:` — replaced with `job:` message
|
|
522
|
+
- All non-exec tasks are batched and produced via `Workflow.produce(jobs)`
|
|
523
|
+
|
|
524
|
+
```ruby
|
|
525
|
+
def self.tasks(messages, original = nil)
|
|
526
|
+
jobs = []
|
|
527
|
+
new = messages.collect do |message|
|
|
528
|
+
if ['task', 'inline_task', 'exec_task'].include?(message[:role])
|
|
529
|
+
workflow, task = info.split(" ").values_at 0, 1
|
|
530
|
+
options = IndiferentHash.parse_options info
|
|
531
|
+
job = workflow.job(task, jobname, options)
|
|
532
|
+
jobs << job unless message[:role] == 'exec_task'
|
|
533
|
+
# exec_task → immediate result as user message
|
|
534
|
+
# inline_task → inline_job message
|
|
535
|
+
# task → job message
|
|
536
|
+
else
|
|
537
|
+
message
|
|
538
|
+
end
|
|
539
|
+
end.flatten
|
|
540
|
+
Workflow.produce(jobs) if jobs.any?
|
|
541
|
+
new
|
|
542
|
+
end
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
#### 8. `Chat.jobs` (tools.rb)
|
|
546
|
+
Resolves `job:` and `inline_job:` messages into tool call/result pairs or file references:
|
|
547
|
+
- `inline_job:` — produces a `file:` message pointing to the Step's path
|
|
548
|
+
- `job:` — loads the Step result and produces a `function_call` + `function_call_output` message pair
|
|
549
|
+
|
|
550
|
+
Handles job states: done (load result), streaming (join), error (optionally return exception as JSON).
|
|
551
|
+
|
|
552
|
+
#### 9. `Chat.files` (files.rb)
|
|
553
|
+
Resolves `file:`, `directory:`, `pdf:`, `image:`, and `step:` messages:
|
|
554
|
+
- **`file:`** — reads file content, wraps in `<file name="path">content</file>` tags, produces a `user:` message
|
|
555
|
+
- **`directory:`** — recursively expands all files in a directory as individual `file:` messages
|
|
556
|
+
- **`pdf:`/`image:`** — resolves the file path (content handled by the backend)
|
|
557
|
+
- **`step:`** — loads a specific Step from the meta-referenced job, produces an `assistant:` message with the step's answer
|
|
558
|
+
|
|
559
|
+
#### Tool extraction (called separately, not in `LLM.chat` pipeline)
|
|
560
|
+
|
|
561
|
+
`Chat.tools(messages)`, `Chat.options(messages)`, and `Chat.associations(messages, kb)` are called separately (typically by `LLM.ask` or the backend) to extract:
|
|
562
|
+
- **Tool definitions** from `tool:`, `introduce:`, `mcp:`, `kb:` messages
|
|
563
|
+
- **Options** from `option:`, `sticky_option:`, `endpoint:`, `model:`, etc.
|
|
564
|
+
- **Associations** from `association:` messages
|
|
565
|
+
|
|
566
|
+
These are side-channel extractions: they **remove** the control messages from the chat and return the extracted data separately.
|
|
567
|
+
|
|
568
|
+
### `Chat.options` (options.rb)
|
|
569
|
+
|
|
570
|
+
```ruby
|
|
571
|
+
def self.options(chat)
|
|
572
|
+
options = IndiferentHash.setup({})
|
|
573
|
+
sticky_options = IndiferentHash.setup({})
|
|
574
|
+
new = []
|
|
575
|
+
|
|
576
|
+
chat.each do |info|
|
|
577
|
+
role = info[:role].to_s
|
|
578
|
+
if %w(endpoint model backend agent).include? role # sticky
|
|
579
|
+
sticky_options[role] = info[:content]
|
|
580
|
+
next # removed
|
|
581
|
+
elsif role == 'persist'
|
|
582
|
+
options[role] = info[:content]
|
|
583
|
+
next
|
|
584
|
+
elsif role == 'previous_response_id'
|
|
585
|
+
sticky_options[role] = info[:content] # sticky, NOT removed
|
|
586
|
+
elsif role == 'format'
|
|
587
|
+
options[role] = resolve_format(info[:content])
|
|
588
|
+
next
|
|
589
|
+
elsif role == 'option'
|
|
590
|
+
options[key] = value # non-sticky
|
|
591
|
+
next
|
|
592
|
+
elsif role == 'sticky_option'
|
|
593
|
+
sticky_options[key] = value
|
|
594
|
+
next
|
|
595
|
+
elsif role == 'assistant'
|
|
596
|
+
options.clear # non-sticky options reset after assistant reply
|
|
597
|
+
end
|
|
598
|
+
new << info
|
|
599
|
+
end
|
|
600
|
+
chat.replace new
|
|
601
|
+
sticky_options.merge(options)
|
|
602
|
+
end
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### `Chat.tools` (tools.rb)
|
|
606
|
+
|
|
607
|
+
```ruby
|
|
608
|
+
def self.tools(messages)
|
|
609
|
+
tool_definitions = IndiferentHash.setup({})
|
|
610
|
+
new = messages.collect do |message|
|
|
611
|
+
role = message[:role]
|
|
612
|
+
case role
|
|
613
|
+
when 'mcp' # MCP tools (stdio or URL)
|
|
614
|
+
when 'tool' # Workflow task tools
|
|
615
|
+
when 'introduce' # Workflow documentation injection
|
|
616
|
+
when 'kb' # Knowledge base tools
|
|
617
|
+
when 'clear_tools' # Reset tool definitions
|
|
618
|
+
else
|
|
619
|
+
message # pass through
|
|
620
|
+
end
|
|
621
|
+
end.compact.flatten
|
|
622
|
+
messages.replace new
|
|
623
|
+
tool_definitions
|
|
624
|
+
end
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
Each tool-related role is consumed (removed from chat) and its definition is accumulated in `tool_definitions`:
|
|
628
|
+
- **`mcp:`** — connects to an MCP server (stdio or HTTP) and loads tool definitions via `LLM.mcp_tools`
|
|
629
|
+
- **`tool:`** — loads a workflow and creates task tool definitions via `LLM.task_tool_definition` or `LLM.workflow_tools`
|
|
630
|
+
- **`introduce:`** — loads a workflow and injects its documentation as a `user:` message (NOT consumed silently)
|
|
631
|
+
- **`kb:`** — loads a knowledge base and creates tool definitions via `LLM.knowledge_base_tool_definition`
|
|
632
|
+
|
|
633
|
+
---
|
|
634
|
+
|
|
635
|
+
## Annotation/Provenance (annotation.rb)
|
|
636
|
+
|
|
637
|
+
### The Annotation mixin
|
|
638
|
+
|
|
639
|
+
As described above, `Chat` uses `extend Annotation` to become an annotation module. This means any Array can be turned into a Chat via `Chat.setup(array)`.
|
|
640
|
+
|
|
641
|
+
### Chat instance methods (defined in annotation.rb)
|
|
642
|
+
|
|
643
|
+
The annotation module adds a rich DSL of instance methods to Chat (Array) objects:
|
|
644
|
+
|
|
645
|
+
#### Message building methods
|
|
646
|
+
```ruby
|
|
647
|
+
def message(role, content) # append {role:, content:}
|
|
648
|
+
def user(content) # append user message
|
|
649
|
+
def system(content) # append system message
|
|
650
|
+
def assistant(content) # append assistant message
|
|
651
|
+
def import(file) # append import: message
|
|
652
|
+
def import_last(file) # append last: message
|
|
653
|
+
def file(file) # append file: message
|
|
654
|
+
def introduce(workflow) # append introduce: message
|
|
655
|
+
def pdf(file) # append pdf: message
|
|
656
|
+
def directory(directory) # append directory: message
|
|
657
|
+
def continue(file) # append continue: message
|
|
658
|
+
def format(format) # append format: message
|
|
659
|
+
def tool(*parts) # append tool: message
|
|
660
|
+
def task(workflow, task_name, inputs = {})
|
|
661
|
+
def exec_task(workflow, task_name, inputs = {})
|
|
662
|
+
def inline_task(workflow, task_name, inputs = {})
|
|
663
|
+
def job(step) # append job: message
|
|
664
|
+
def inline_job(step) # append inline_job: message
|
|
665
|
+
def association(name, path, options = {})
|
|
666
|
+
def tag(content, name=nil, tag=:file, role=:user)
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
#### Option/configuration methods
|
|
670
|
+
```ruby
|
|
671
|
+
def option(name, value)
|
|
672
|
+
def endpoint(value)
|
|
673
|
+
def model(value)
|
|
674
|
+
def image(file)
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
#### Inference methods
|
|
678
|
+
```ruby
|
|
679
|
+
def ask(options = {}) # calls LLM.ask with this chat
|
|
680
|
+
def chat(options = {}) # ask + append response
|
|
681
|
+
def json(*args, only_ask:, **) # ask with JSON format, parse result
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
#### Chat composition methods
|
|
685
|
+
```ruby
|
|
686
|
+
def self.follow(intro, coda) # merges two chats, handling previous_response_id
|
|
687
|
+
def append(coda) # append messages to this chat
|
|
688
|
+
def follow(coda) # same as append (alias semantics)
|
|
689
|
+
def prepend(intro) # prepend messages to this chat
|
|
690
|
+
def branch # return a duplicate (annotated copy)
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
#### Provenance and reporting methods
|
|
694
|
+
```ruby
|
|
695
|
+
def print # serialize to text
|
|
696
|
+
def final # last message after purge
|
|
697
|
+
def purge # remove previous_response_id messages
|
|
698
|
+
def shed # return a new chat with just the final message
|
|
699
|
+
def answer # content of the final message
|
|
700
|
+
def save(path, force=true)
|
|
701
|
+
def write(path, force=true)
|
|
702
|
+
def write_answer(path, force=true)
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
#### Meta/provenance inspection methods
|
|
706
|
+
```ruby
|
|
707
|
+
def add_meta(key, value) # add/update meta key-value
|
|
708
|
+
def meta # parse last meta message
|
|
709
|
+
def job_paths / jobs # extract job paths from meta
|
|
710
|
+
def last_job # last job reference from meta
|
|
711
|
+
def message_index # lineage IDs for all messages
|
|
712
|
+
def job_chat_files # find chat files from referenced jobs
|
|
713
|
+
def job_chats # load those chat files
|
|
714
|
+
def job_agent_chat_files # find agent chat files from jobs
|
|
715
|
+
def job_agent_chats # load agent chat files
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
### Meta message system
|
|
719
|
+
|
|
720
|
+
Meta messages are the **provenance backbone** of the Chat system. They carry key=value pairs serialized as space-delimited strings:
|
|
721
|
+
|
|
722
|
+
```ruby
|
|
723
|
+
def self.serialize_meta(meta)
|
|
724
|
+
keys = meta.keys.sort_by { |key| String === meta[key] ? meta[key].length : 0 }
|
|
725
|
+
keys.collect { |key| [key, meta[key]] * '=' } * ' '
|
|
726
|
+
end
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
Example meta content: `job=/path/to/job pt_c=1000 ct_c=500 tt_c=2000`
|
|
730
|
+
|
|
731
|
+
Key meta fields:
|
|
732
|
+
| Key | Meaning |
|
|
733
|
+
|---|---|
|
|
734
|
+
| `job` | Path to the workflow job that produced this chat segment |
|
|
735
|
+
| `pt_c` | Prompt token count |
|
|
736
|
+
| `ct_c` | Completion token count |
|
|
737
|
+
| `tt_c` | Total token count |
|
|
738
|
+
|
|
739
|
+
### Meta processing (meta.rb)
|
|
740
|
+
|
|
741
|
+
`Chat.meta(messages)` extracts and **removes** meta messages from the chat:
|
|
742
|
+
|
|
743
|
+
```ruby
|
|
744
|
+
def self.meta(messages)
|
|
745
|
+
meta_messages = []
|
|
746
|
+
messages.reject! do |message|
|
|
747
|
+
match = message[:role].to_s == 'meta'
|
|
748
|
+
meta_messages << message if match
|
|
749
|
+
match
|
|
750
|
+
end
|
|
751
|
+
return nil if meta_messages.empty?
|
|
752
|
+
|
|
753
|
+
metas = meta_messages.collect { |message| parse_meta(message[:content]) }
|
|
754
|
+
current = IndiferentHash.setup(metas.last.dup)
|
|
755
|
+
# Find the last checkpoint with token counts
|
|
756
|
+
checkpoint = metas.reverse.find do |meta|
|
|
757
|
+
%w[pt_c ct_c tt_c].any? { |name| meta.include?(name) }
|
|
758
|
+
end
|
|
759
|
+
if checkpoint
|
|
760
|
+
%w[pt_c ct_c tt_c].each do |name|
|
|
761
|
+
current[name] = checkpoint[name] if checkpoint.include?(name)
|
|
762
|
+
end
|
|
763
|
+
end
|
|
764
|
+
current
|
|
765
|
+
end
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
The meta system is designed so that:
|
|
769
|
+
- Meta messages are **local bookkeeping** and are **not sent to the provider**
|
|
770
|
+
- The last checkpoint with token counts supplies the running total for the next request
|
|
771
|
+
- Job metadata deliberately contributes no token counts
|
|
772
|
+
|
|
773
|
+
### Message lineage and tracing
|
|
774
|
+
|
|
775
|
+
`message_index` assigns a unique digest-based ID to each message (excluding meta from the chain):
|
|
776
|
+
|
|
777
|
+
```ruby
|
|
778
|
+
def message_index
|
|
779
|
+
previous = nil
|
|
780
|
+
collect do |message|
|
|
781
|
+
id = Misc.digest([previous, role, content])
|
|
782
|
+
info = { id: id, role: role.to_sym, prev: previous, fingerprint: Log.truncate_string(content) }
|
|
783
|
+
if role == 'meta'
|
|
784
|
+
info[:meta] = Chat.parse_meta(content)
|
|
785
|
+
else
|
|
786
|
+
previous = id # meta doesn't advance the lineage chain
|
|
787
|
+
end
|
|
788
|
+
info
|
|
789
|
+
end
|
|
790
|
+
end
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
`trace_indices` segments the chat into response segments, each beginning with a meta marker:
|
|
794
|
+
|
|
795
|
+
```ruby
|
|
796
|
+
def self.trace_indices(indices)
|
|
797
|
+
# A meta starts a segment; the segment continues until another meta,
|
|
798
|
+
# a new user/system turn, or the end of the chat.
|
|
799
|
+
# Consecutive/final metas with no covered messages are "orphan" records.
|
|
800
|
+
end
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
### Projection
|
|
804
|
+
|
|
805
|
+
`Chat.project(job, messages)` creates a chat segment from job output with a producer marker:
|
|
806
|
+
|
|
807
|
+
```ruby
|
|
808
|
+
def self.project(job, messages)
|
|
809
|
+
projected = Array(messages).reject { |m| m[:role].to_s == 'meta' }.collect(&:dup)
|
|
810
|
+
return [] if projected.empty?
|
|
811
|
+
[{ role: :meta, content: serialize_meta(job: job.to_s) }] + projected
|
|
812
|
+
end
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
### Job chat file discovery
|
|
816
|
+
|
|
817
|
+
The system can trace provenance through job dependencies:
|
|
818
|
+
|
|
819
|
+
```ruby
|
|
820
|
+
def self.job_chat_files(job, seen = Set.new)
|
|
821
|
+
# Collects result chats and logged agent chats for a job and ALL its dependencies
|
|
822
|
+
# Uses a Set to avoid infinite recursion on cycles and shared dependencies
|
|
823
|
+
end
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
---
|
|
827
|
+
|
|
828
|
+
## Key Abstractions and Design Patterns
|
|
829
|
+
|
|
830
|
+
### 1. Module-as-Annotation (Annotation mixin)
|
|
831
|
+
|
|
832
|
+
The most distinctive pattern. `Chat` is a module that extends `Annotation`, enabling any Array to be "annotated" as a Chat. This avoids subclassing Array (which is fragile) and allows Chat behavior to be mixed into existing arrays.
|
|
833
|
+
|
|
834
|
+
```ruby
|
|
835
|
+
module Chat
|
|
836
|
+
extend Annotation
|
|
837
|
+
# instance methods become available on Arrays via Chat.setup(array)
|
|
838
|
+
end
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
### 2. Open Module Pattern
|
|
842
|
+
|
|
843
|
+
`module Chat` is opened across 10+ files, each adding class or instance methods. This is idiomatic Ruby for organizing large modules:
|
|
844
|
+
|
|
845
|
+
```
|
|
846
|
+
chat.rb → LLM factory methods
|
|
847
|
+
chat/parse.rb → Chat.parse, Chat.print (class methods)
|
|
848
|
+
chat/process.rb → Chat.content_tokens, Chat.indiferent (class methods)
|
|
849
|
+
chat/process/clear.rb → Chat.clear, Chat.clean, Chat.purge (class methods)
|
|
850
|
+
chat/process/files.rb → Chat.imports, Chat.files (class methods)
|
|
851
|
+
chat/process/options.rb → Chat.config, Chat.options (class methods)
|
|
852
|
+
chat/process/tools.rb → Chat.tasks, Chat.jobs, Chat.tools (class methods)
|
|
853
|
+
chat/process/meta.rb → Chat.meta + provenance methods (class + instance methods)
|
|
854
|
+
chat/annotation.rb → Chat instance methods (message building, inference, reporting)
|
|
855
|
+
chat/prompt.rb → Chat.prepare_prompt, Chat.shorten_tools (class methods)
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
### 3. Text-as-Serialization Format
|
|
859
|
+
|
|
860
|
+
The entire chat is serialized to/from a human-readable text format. This is both the persistence format and the authoring format. Users can write chat files directly in a text editor:
|
|
861
|
+
|
|
862
|
+
```
|
|
863
|
+
system: You are a helpful assistant.
|
|
864
|
+
|
|
865
|
+
user: What is 2+2?
|
|
866
|
+
|
|
867
|
+
assistant: 4
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
### 4. Side-Channel Extraction Pattern
|
|
871
|
+
|
|
872
|
+
Tool definitions, options, and associations are extracted from the chat **separately** from the main processing pipeline. They modify the message array in place (using `messages.replace new`) and return the extracted data:
|
|
873
|
+
|
|
874
|
+
```ruby
|
|
875
|
+
def self.tools(messages)
|
|
876
|
+
tool_definitions = {}
|
|
877
|
+
new = messages.collect { ... }.compact.flatten
|
|
878
|
+
messages.replace new # mutate original array
|
|
879
|
+
tool_definitions # return extracted data
|
|
880
|
+
end
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
### 5. Clear/Cut Semantics
|
|
884
|
+
|
|
885
|
+
The `clear:` role implements a **cut point** — everything before the last `clear:` message is discarded. This allows chat templates to include setup that is stripped before the actual conversation:
|
|
886
|
+
|
|
887
|
+
```
|
|
888
|
+
system: Setup instructions
|
|
889
|
+
clear:
|
|
890
|
+
user: Actual question
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
### 6. Sticky vs. Non-Sticky Options
|
|
894
|
+
|
|
895
|
+
Options have two lifetimes:
|
|
896
|
+
- **Non-sticky** (`option:`, `persist:`, `format:`) — reset after an `assistant:` reply
|
|
897
|
+
- **Sticky** (`endpoint:`, `model:`, `backend:`, `agent:`, `sticky_option:`, `previous_response_id:`) — persist across the entire conversation
|
|
898
|
+
|
|
899
|
+
### 7. IndiferentHash everywhere
|
|
900
|
+
|
|
901
|
+
All message hashes use `IndiferentHash.setup` for symbol/string key indifference, avoiding `Symbol === String` comparison bugs.
|
|
902
|
+
|
|
903
|
+
### 8. Role-Polymorphic Content
|
|
904
|
+
|
|
905
|
+
The same `content` field holds different data types depending on the role:
|
|
906
|
+
- String text for `user`/`assistant`/`system`
|
|
907
|
+
- JSON for `function_call`/`function_call_output`
|
|
908
|
+
- Space-delimited key=value for `meta`
|
|
909
|
+
- Space-delimited key value for `option`
|
|
910
|
+
- File paths for `file`/`pdf`/`image`/`import`
|
|
911
|
+
- Workflow specs for `task`/`tool`
|
|
912
|
+
|
|
913
|
+
### 9. Protected Block Parsing
|
|
914
|
+
|
|
915
|
+
The parser's multi-format protected block handling (backtick fences, `[[...]]`, XML tags, command output markers) allows embedding code, structured data, and command output within chat text without false-positive role header detection.
|
|
916
|
+
|
|
917
|
+
### 10. Thread-Local Access Control
|
|
918
|
+
|
|
919
|
+
Tool processing uses thread-local variables for directory access control:
|
|
920
|
+
|
|
921
|
+
```ruby
|
|
922
|
+
def self.allow_dir(dir)
|
|
923
|
+
Thread.current['allowed_dirs'] ||= []
|
|
924
|
+
return if Thread.current['allowed_dirs'].include?(dir)
|
|
925
|
+
Thread.current['allowed_dirs'] << dir
|
|
926
|
+
end
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
This provides per-thread sandboxing of file operations without explicit parameter passing.
|
|
930
|
+
|
|
931
|
+
---
|
|
932
|
+
|
|
933
|
+
## Summary Table: File Inventory
|
|
934
|
+
|
|
935
|
+
| File | Lines | Responsibility |
|
|
936
|
+
|---|---|---|
|
|
937
|
+
| `chat.rb` | ~70 | `LLM` module factory; orchestrates pipeline |
|
|
938
|
+
| `chat/parse.rb` | ~190 | Text ↔ messages parsing; `print`/`print_brief` |
|
|
939
|
+
| `chat/persist.rb` | ~24 | Persist save/load drivers for `:chat` type |
|
|
940
|
+
| `chat/process.rb` | ~17 | `content_tokens`, `indiferent` helpers |
|
|
941
|
+
| `chat/process/clear.rb` | ~63 | `clear`, `clean`, `purge`, `pull` |
|
|
942
|
+
| `chat/process/files.rb` | ~103 | `imports`, `files`, `find_file`, `tag` |
|
|
943
|
+
| `chat/process/options.rb` | ~72 | `config`, `options` (sticky/non-sticky) |
|
|
944
|
+
| `chat/process/tools.rb` | ~250 | `tasks`, `jobs`, `tools`, `associations`, access control |
|
|
945
|
+
| `chat/annotation.rb` | ~270 | Chat instance DSL (message building, inference, provenance) |
|
|
946
|
+
| `chat/prompt.rb` | ~190 | Context strategies (`shorten_tools`), prompt preparation |
|