small-model-harness 0.1.0__py3-none-any.whl
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.
- small_model_harness/__init__.py +874 -0
- small_model_harness/confidence.py +241 -0
- small_model_harness/pydantic_deep_integration.py +243 -0
- small_model_harness/tool_disclosure.py +231 -0
- small_model_harness/tool_repair.py +432 -0
- small_model_harness-0.1.0.dist-info/METADATA +426 -0
- small_model_harness-0.1.0.dist-info/RECORD +10 -0
- small_model_harness-0.1.0.dist-info/WHEEL +5 -0
- small_model_harness-0.1.0.dist-info/licenses/LICENSE +21 -0
- small_model_harness-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
"""Small-model harness — session memory, context pressure, adaptive steering.
|
|
2
|
+
|
|
3
|
+
Provides budget tracking, audit trail, result quality scoring, file handling
|
|
4
|
+
(compaction + type filtering + transcription), and session-level intelligence
|
|
5
|
+
for small on-device models (2B--4B).
|
|
6
|
+
|
|
7
|
+
Key capabilities for tiny models:
|
|
8
|
+
- Session memory: track what worked/failed, prevent repetition
|
|
9
|
+
- Context pressure: monitor token usage, trigger compaction
|
|
10
|
+
- Adaptive steering: inject "avoid this" hints after failures
|
|
11
|
+
- Loop detection: break same-tool-same-args repetition
|
|
12
|
+
- Response compaction: extract essentials from tool responses
|
|
13
|
+
|
|
14
|
+
Built on pydantic v2 for validated models, JSON schema generation, and
|
|
15
|
+
clean serialization.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import mimetypes
|
|
24
|
+
import re
|
|
25
|
+
import time
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Literal
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Constants
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
SUPPORTED_EXTENSIONS = {".md", ".txt", ".pdf", ".epub"}
|
|
41
|
+
SUPPORTED_MIME_TYPES = {
|
|
42
|
+
"text/markdown",
|
|
43
|
+
"text/plain",
|
|
44
|
+
"application/pdf",
|
|
45
|
+
"application/epub+zip",
|
|
46
|
+
"application/octet-stream",
|
|
47
|
+
}
|
|
48
|
+
MAX_FILE_SIZE = 10 * 1024 * 1024
|
|
49
|
+
|
|
50
|
+
# Absolute ceiling for compacted tokens (used when n_ctx is unknown)
|
|
51
|
+
_ABSOLUTE_MAX_TOKENS = 8000
|
|
52
|
+
|
|
53
|
+
# Small-model thresholds
|
|
54
|
+
_FAILURE_STEER_THRESHOLD = 2 # failures before marking tool as "avoid"
|
|
55
|
+
_LOOP_DETECTION_WINDOW = 3 # consecutive same-tool calls to trigger loop break
|
|
56
|
+
_CONTEXT_PRESSURE_WARN = 0.75 # warn when context is 75% full
|
|
57
|
+
_CONTEXT_PRESSURE_CRITICAL = 0.9 # trigger aggressive compaction at 90%
|
|
58
|
+
_RECENT_HISTORY_SIZE = 10 # last N tool calls for loop detection
|
|
59
|
+
_MAX_STEERING_HINTS = 5 # cap hints to avoid prompt bloat
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Pydantic models
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
ExecutionStatus = Literal["planned", "running", "completed", "failed", "skipped"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FileInput(BaseModel):
|
|
70
|
+
"""Represents a file input to the assistant."""
|
|
71
|
+
|
|
72
|
+
model_config = ConfigDict(frozen=True)
|
|
73
|
+
|
|
74
|
+
path: str
|
|
75
|
+
filename: str
|
|
76
|
+
extension: str
|
|
77
|
+
mime_type: str
|
|
78
|
+
size_bytes: int = Field(ge=0)
|
|
79
|
+
content: str = ""
|
|
80
|
+
compacted: bool = False
|
|
81
|
+
truncated: bool = False
|
|
82
|
+
error: str | None = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def is_supported(self) -> bool:
|
|
86
|
+
return self.extension.lower() in SUPPORTED_EXTENSIONS
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def is_text_based(self) -> bool:
|
|
90
|
+
return self.extension.lower() in {".md", ".txt"}
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def needs_transcription(self) -> bool:
|
|
94
|
+
return self.extension.lower() in {".pdf", ".epub"}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ExecutionRecord(BaseModel):
|
|
98
|
+
"""Audit trail entry for a single tool execution."""
|
|
99
|
+
|
|
100
|
+
model_config = ConfigDict(frozen=True)
|
|
101
|
+
|
|
102
|
+
timestamp: str
|
|
103
|
+
tool_name: str = Field(min_length=1)
|
|
104
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
105
|
+
status: ExecutionStatus
|
|
106
|
+
result: str | None = None
|
|
107
|
+
error: str | None = None
|
|
108
|
+
duration_ms: float = Field(default=0.0, ge=0.0)
|
|
109
|
+
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
110
|
+
retry_count: int = Field(default=0, ge=0)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class HarnessState(BaseModel):
|
|
114
|
+
"""Complete harness state for a conversation session.
|
|
115
|
+
|
|
116
|
+
Tracks session-level intelligence for small models: tool success/failure
|
|
117
|
+
stats, recent call history for loop detection, context pressure, and
|
|
118
|
+
dynamic steering hints injected into the system prompt.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
model_config = ConfigDict(
|
|
122
|
+
validate_assignment=True,
|
|
123
|
+
json_schema_extra={
|
|
124
|
+
"examples": [
|
|
125
|
+
{
|
|
126
|
+
"session_id": "abc123",
|
|
127
|
+
"started_at": "2026-01-01T00:00:00Z",
|
|
128
|
+
"budget_remaining": 8,
|
|
129
|
+
"n_ctx": 4096,
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
session_id: str
|
|
136
|
+
started_at: str
|
|
137
|
+
total_tool_calls: int = Field(default=0, ge=0)
|
|
138
|
+
successful_calls: int = Field(default=0, ge=0)
|
|
139
|
+
failed_calls: int = Field(default=0, ge=0)
|
|
140
|
+
total_duration_ms: float = Field(default=0.0, ge=0.0)
|
|
141
|
+
budget_remaining: int = Field(default=100, ge=0)
|
|
142
|
+
execution_history: list[ExecutionRecord] = Field(default_factory=list)
|
|
143
|
+
file_inputs: list[FileInput] = Field(default_factory=list)
|
|
144
|
+
warnings: list[str] = Field(default_factory=list)
|
|
145
|
+
|
|
146
|
+
# --- Small-model session memory ---
|
|
147
|
+
tool_failure_counts: dict[str, int] = Field(default_factory=dict)
|
|
148
|
+
tool_success_counts: dict[str, int] = Field(default_factory=dict)
|
|
149
|
+
recent_tool_calls: list[str] = Field(default_factory=list)
|
|
150
|
+
avoided_tools: set[str] = Field(default_factory=set)
|
|
151
|
+
steering_hints: list[str] = Field(default_factory=list)
|
|
152
|
+
context_pressure: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
153
|
+
n_ctx: int = Field(default=0, ge=0)
|
|
154
|
+
|
|
155
|
+
def record_execution(self, record: ExecutionRecord) -> None:
|
|
156
|
+
"""Record a tool execution and update session intelligence."""
|
|
157
|
+
self.execution_history.append(record)
|
|
158
|
+
self.total_tool_calls += 1
|
|
159
|
+
self.total_duration_ms += record.duration_ms
|
|
160
|
+
self.budget_remaining -= 1
|
|
161
|
+
|
|
162
|
+
if record.status == "completed":
|
|
163
|
+
self.successful_calls += 1
|
|
164
|
+
self.tool_success_counts[record.tool_name] = self.tool_success_counts.get(record.tool_name, 0) + 1
|
|
165
|
+
elif record.status == "failed":
|
|
166
|
+
self.failed_calls += 1
|
|
167
|
+
self.tool_failure_counts[record.tool_name] = self.tool_failure_counts.get(record.tool_name, 0) + 1
|
|
168
|
+
|
|
169
|
+
# Track recent calls for loop detection
|
|
170
|
+
self.recent_tool_calls.append(record.tool_name)
|
|
171
|
+
if len(self.recent_tool_calls) > _RECENT_HISTORY_SIZE:
|
|
172
|
+
self.recent_tool_calls = self.recent_tool_calls[-_RECENT_HISTORY_SIZE:]
|
|
173
|
+
|
|
174
|
+
# Update steering hints
|
|
175
|
+
self._update_steering_hints()
|
|
176
|
+
|
|
177
|
+
def _update_steering_hints(self) -> None:
|
|
178
|
+
"""Regenerate steering hints from current session state."""
|
|
179
|
+
hints: list[str] = []
|
|
180
|
+
|
|
181
|
+
# Tools that failed too many times
|
|
182
|
+
for tool, count in sorted(self.tool_failure_counts.items(), key=lambda x: -x[1]):
|
|
183
|
+
if count >= _FAILURE_STEER_THRESHOLD:
|
|
184
|
+
self.avoided_tools.add(tool)
|
|
185
|
+
success = self.tool_success_counts.get(tool, 0)
|
|
186
|
+
hints.append(
|
|
187
|
+
f"Do NOT use {tool} — it failed {count} time(s) ({success} success, {count} failures this session)."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Loop detection
|
|
191
|
+
loop_tool = detect_failure_loop(self.recent_tool_calls)
|
|
192
|
+
if loop_tool:
|
|
193
|
+
hints.append(f"You are repeating {loop_tool} in a loop. Stop and try a completely different approach.")
|
|
194
|
+
|
|
195
|
+
# Context pressure
|
|
196
|
+
if self.context_pressure >= _CONTEXT_PRESSURE_CRITICAL:
|
|
197
|
+
hints.append("Context is nearly full. Give a concise final answer now — do not call more tools.")
|
|
198
|
+
elif self.context_pressure >= _CONTEXT_PRESSURE_WARN:
|
|
199
|
+
hints.append("Context is getting full. Minimize tool calls and wrap up soon.")
|
|
200
|
+
|
|
201
|
+
self.steering_hints = hints[:_MAX_STEERING_HINTS]
|
|
202
|
+
|
|
203
|
+
def update_context_pressure(self, estimated_tokens: int, n_ctx: int) -> None:
|
|
204
|
+
"""Update context pressure estimate (0.0 to 1.0)."""
|
|
205
|
+
self.n_ctx = n_ctx
|
|
206
|
+
if n_ctx > 0:
|
|
207
|
+
self.context_pressure = min(1.0, estimated_tokens / n_ctx)
|
|
208
|
+
self._update_steering_hints()
|
|
209
|
+
|
|
210
|
+
def get_steering_prompt(self) -> str:
|
|
211
|
+
"""Get steering hints to inject into the system prompt.
|
|
212
|
+
|
|
213
|
+
Returns empty string when no hints are active.
|
|
214
|
+
"""
|
|
215
|
+
if not self.steering_hints:
|
|
216
|
+
return ""
|
|
217
|
+
return "\n\n# Session Guidance\n" + "\n".join(f"- {h}" for h in self.steering_hints)
|
|
218
|
+
|
|
219
|
+
def get_avoided_tools_prompt(self) -> str:
|
|
220
|
+
"""List tools the model should avoid in this session."""
|
|
221
|
+
if not self.avoided_tools:
|
|
222
|
+
return ""
|
|
223
|
+
names = ", ".join(sorted(self.avoided_tools))
|
|
224
|
+
return f"\n\nAvoid these tools (they failed this session): {names}"
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def success_rate(self) -> float:
|
|
228
|
+
if self.total_tool_calls == 0:
|
|
229
|
+
return 1.0
|
|
230
|
+
return self.successful_calls / self.total_tool_calls
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def is_budget_exhausted(self) -> bool:
|
|
234
|
+
return self.budget_remaining <= 0
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def is_context_critical(self) -> bool:
|
|
238
|
+
return self.context_pressure >= _CONTEXT_PRESSURE_CRITICAL
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def is_context_warned(self) -> bool:
|
|
242
|
+
return self.context_pressure >= _CONTEXT_PRESSURE_WARN
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# Session factory
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def create_harness_session(
|
|
251
|
+
session_id: str | None = None,
|
|
252
|
+
budget: int | None = None,
|
|
253
|
+
n_ctx: int | None = None,
|
|
254
|
+
) -> HarnessState:
|
|
255
|
+
"""Create a new harness session with a fresh budget.
|
|
256
|
+
|
|
257
|
+
``budget`` caps tool calls per session. When omitted, defaults to 100.
|
|
258
|
+
The caller should derive a tighter budget from n_ctx for small models:
|
|
259
|
+
``min(100, max(5, n_ctx // 500))``.
|
|
260
|
+
|
|
261
|
+
``n_ctx`` sets the context window size for pressure monitoring.
|
|
262
|
+
"""
|
|
263
|
+
if session_id is None:
|
|
264
|
+
session_id = hashlib.md5(str(time.time()).encode()).hexdigest()[:12]
|
|
265
|
+
|
|
266
|
+
if budget is None:
|
|
267
|
+
if n_ctx is not None:
|
|
268
|
+
budget = min(100, max(5, n_ctx // 500))
|
|
269
|
+
else:
|
|
270
|
+
budget = 100
|
|
271
|
+
|
|
272
|
+
return HarnessState(
|
|
273
|
+
session_id=session_id,
|
|
274
|
+
started_at=datetime.now(timezone.utc).isoformat(),
|
|
275
|
+
budget_remaining=budget,
|
|
276
|
+
n_ctx=n_ctx or 0,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Loop detection
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def detect_failure_loop(
|
|
286
|
+
recent_calls: list[str],
|
|
287
|
+
window: int = _LOOP_DETECTION_WINDOW,
|
|
288
|
+
) -> str | None:
|
|
289
|
+
"""Detect if the model is stuck calling the same tool repeatedly.
|
|
290
|
+
|
|
291
|
+
Returns the tool name if a loop is detected, None otherwise.
|
|
292
|
+
"""
|
|
293
|
+
if len(recent_calls) < window:
|
|
294
|
+
return None
|
|
295
|
+
last_n = recent_calls[-window:]
|
|
296
|
+
if len(set(last_n)) == 1:
|
|
297
|
+
return last_n[0]
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
# Context pressure estimation
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def estimate_tokens(text: str) -> int:
|
|
307
|
+
"""Rough token estimate (~4 chars per token for English)."""
|
|
308
|
+
return max(1, len(text) // 4)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def estimate_messages_tokens(messages: list[dict]) -> int:
|
|
312
|
+
"""Estimate total token count for a message list."""
|
|
313
|
+
total = 0
|
|
314
|
+
for msg in messages:
|
|
315
|
+
content = msg.get("content", "")
|
|
316
|
+
if content:
|
|
317
|
+
total += estimate_tokens(content)
|
|
318
|
+
# Tool calls add overhead
|
|
319
|
+
if "tool_calls" in msg:
|
|
320
|
+
total += estimate_tokens(json.dumps(msg["tool_calls"]))
|
|
321
|
+
return total
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
# Smart response compaction
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def compact_tool_response(
|
|
330
|
+
tool_name: str,
|
|
331
|
+
response: str,
|
|
332
|
+
max_tokens: int = 500,
|
|
333
|
+
) -> tuple[str, bool]:
|
|
334
|
+
"""Compact a tool response to essential information.
|
|
335
|
+
|
|
336
|
+
Different strategies per tool type:
|
|
337
|
+
- JSON responses: extract key fields, drop verbose metadata
|
|
338
|
+
- List responses: summarize count + first few items
|
|
339
|
+
- Error responses: preserve the error message
|
|
340
|
+
- Audio file responses: keep path + metadata only
|
|
341
|
+
|
|
342
|
+
Returns (compacted_response, was_compacted).
|
|
343
|
+
"""
|
|
344
|
+
if not response:
|
|
345
|
+
return response, False
|
|
346
|
+
|
|
347
|
+
estimated = estimate_tokens(response)
|
|
348
|
+
if estimated <= max_tokens:
|
|
349
|
+
return response, False
|
|
350
|
+
|
|
351
|
+
# Error responses — always keep full
|
|
352
|
+
if response.startswith(("Error", "Tool execution failed")):
|
|
353
|
+
return response, False
|
|
354
|
+
|
|
355
|
+
# Try JSON extraction
|
|
356
|
+
try:
|
|
357
|
+
data = json.loads(response)
|
|
358
|
+
compacted = _compact_json_response(tool_name, data, max_tokens)
|
|
359
|
+
if compacted != response:
|
|
360
|
+
return compacted, True
|
|
361
|
+
except (json.JSONDecodeError, TypeError):
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
# List-like responses — summarize
|
|
365
|
+
if response.startswith(("[", "List of", "Available")):
|
|
366
|
+
lines = response.split("\n")
|
|
367
|
+
if len(lines) > 20:
|
|
368
|
+
summary = "\n".join(lines[:10]) + f"\n\n[... {len(lines) - 10} more items ...]"
|
|
369
|
+
return summary, True
|
|
370
|
+
|
|
371
|
+
# Generic truncation as fallback
|
|
372
|
+
max_chars = max_tokens * 4
|
|
373
|
+
if len(response) > max_chars:
|
|
374
|
+
return response[:max_chars] + "\n[... truncated ...]", True
|
|
375
|
+
|
|
376
|
+
return response, False
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _compact_json_response(tool_name: str, data: Any, max_tokens: int) -> str:
|
|
380
|
+
"""Extract essential info from a JSON tool response."""
|
|
381
|
+
if isinstance(data, dict):
|
|
382
|
+
# Audio file responses — keep path + key metadata
|
|
383
|
+
if "audio_file" in data or "file_path" in data:
|
|
384
|
+
essential = {}
|
|
385
|
+
for key in ("audio_file", "file_path", "duration", "sample_rate", "format", "size_bytes"):
|
|
386
|
+
if key in data:
|
|
387
|
+
essential[key] = data[key]
|
|
388
|
+
if "effects_applied" in data:
|
|
389
|
+
essential["effects_applied"] = data["effects_applied"]
|
|
390
|
+
compacted = json.dumps(essential, indent=None)
|
|
391
|
+
if estimate_tokens(compacted) <= max_tokens:
|
|
392
|
+
return compacted
|
|
393
|
+
|
|
394
|
+
# Profile/voice responses — keep names and IDs
|
|
395
|
+
if "profiles" in data or "voices" in data or "items" in data:
|
|
396
|
+
list_key = next((k for k in ("profiles", "voices", "items") if k in data), None)
|
|
397
|
+
if list_key and isinstance(data[list_key], list):
|
|
398
|
+
items = data[list_key]
|
|
399
|
+
compacted_items = []
|
|
400
|
+
for item in items[:10]:
|
|
401
|
+
if isinstance(item, dict):
|
|
402
|
+
compacted_items.append(
|
|
403
|
+
{k: item[k] for k in ("id", "name", "type", "language", "status") if k in item}
|
|
404
|
+
)
|
|
405
|
+
else:
|
|
406
|
+
compacted_items.append(str(item)[:100])
|
|
407
|
+
data[list_key] = compacted_items
|
|
408
|
+
if len(items) > 10:
|
|
409
|
+
data[f"{list_key}_count"] = len(items)
|
|
410
|
+
compacted = json.dumps(data, indent=None)
|
|
411
|
+
if estimate_tokens(compacted) <= max_tokens:
|
|
412
|
+
return compacted
|
|
413
|
+
|
|
414
|
+
# Loudness/analysis responses — keep numbers
|
|
415
|
+
if any(k in data for k in ("lufs", "loudness", "peak", "rms", "pitch")):
|
|
416
|
+
essential = {k: v for k, v in data.items() if isinstance(v, (int, float, str))}
|
|
417
|
+
compacted = json.dumps(essential, indent=None)
|
|
418
|
+
if estimate_tokens(compacted) <= max_tokens:
|
|
419
|
+
return compacted
|
|
420
|
+
|
|
421
|
+
# List responses
|
|
422
|
+
if isinstance(data, list) and len(data) > 10:
|
|
423
|
+
compacted = json.dumps(data[:10]) + f" [+{len(data) - 10} more]"
|
|
424
|
+
if estimate_tokens(compacted) <= max_tokens:
|
|
425
|
+
return compacted
|
|
426
|
+
|
|
427
|
+
# Fallback: full JSON
|
|
428
|
+
return json.dumps(data, indent=None)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ---------------------------------------------------------------------------
|
|
432
|
+
# File handling: compaction, type filtering, transcription
|
|
433
|
+
# ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def validate_file_input(file_path: str | Path) -> FileInput:
|
|
437
|
+
"""Validate a file input, check type and size limits."""
|
|
438
|
+
path = Path(file_path)
|
|
439
|
+
stat = path.stat()
|
|
440
|
+
extension = path.suffix.lower()
|
|
441
|
+
mime_type, _ = mimetypes.guess_type(str(path))
|
|
442
|
+
|
|
443
|
+
file_input = FileInput(
|
|
444
|
+
path=str(path),
|
|
445
|
+
filename=path.name,
|
|
446
|
+
extension=extension,
|
|
447
|
+
mime_type=mime_type or "application/octet-stream",
|
|
448
|
+
size_bytes=stat.st_size,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
if not file_input.is_supported:
|
|
452
|
+
error_msg = f"Unsupported file type: {extension}. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
|
|
453
|
+
logger.warning("Rejected file: %s (%s)", path.name, error_msg)
|
|
454
|
+
return file_input.model_copy(update={"error": error_msg})
|
|
455
|
+
|
|
456
|
+
if stat.st_size > MAX_FILE_SIZE:
|
|
457
|
+
error_msg = f"File too large: {stat.st_size} bytes (max {MAX_FILE_SIZE})"
|
|
458
|
+
logger.warning("Rejected file: %s (%s)", path.name, error_msg)
|
|
459
|
+
return file_input.model_copy(update={"error": error_msg})
|
|
460
|
+
|
|
461
|
+
return file_input
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def read_text_file(path: Path) -> str:
|
|
465
|
+
"""Read a text file with encoding fallback."""
|
|
466
|
+
for encoding in ("utf-8", "latin-1", "cp1252"):
|
|
467
|
+
try:
|
|
468
|
+
return path.read_text(encoding=encoding)
|
|
469
|
+
except UnicodeDecodeError:
|
|
470
|
+
continue
|
|
471
|
+
return path.read_bytes().decode("utf-8", errors="replace")
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def compact_text(
|
|
475
|
+
content: str,
|
|
476
|
+
max_tokens: int | None = None,
|
|
477
|
+
n_ctx: int | None = None,
|
|
478
|
+
) -> tuple[str, bool]:
|
|
479
|
+
"""Compact text content by removing excessive whitespace and truncating.
|
|
480
|
+
|
|
481
|
+
When ``n_ctx`` is provided, the token budget is derived as
|
|
482
|
+
``min(_ABSOLUTE_MAX_TOKENS, n_ctx // 4)`` — file content should never
|
|
483
|
+
exceed a quarter of the context window. ``max_tokens`` overrides this
|
|
484
|
+
when given.
|
|
485
|
+
|
|
486
|
+
Returns (compacted_content, was_truncated).
|
|
487
|
+
"""
|
|
488
|
+
if max_tokens is None:
|
|
489
|
+
if n_ctx is not None:
|
|
490
|
+
max_tokens = min(_ABSOLUTE_MAX_TOKENS, max(512, n_ctx // 4))
|
|
491
|
+
else:
|
|
492
|
+
max_tokens = _ABSOLUTE_MAX_TOKENS
|
|
493
|
+
|
|
494
|
+
content = re.sub(r"\n{3,}", "\n\n", content)
|
|
495
|
+
content = re.sub(r"[ \t]+\n", "\n", content)
|
|
496
|
+
|
|
497
|
+
estimated_tokens = len(content) // 4
|
|
498
|
+
if estimated_tokens <= max_tokens:
|
|
499
|
+
return content, False
|
|
500
|
+
|
|
501
|
+
max_chars = max_tokens * 4
|
|
502
|
+
keep_start = int(max_chars * 0.7)
|
|
503
|
+
keep_end = max_chars - keep_start
|
|
504
|
+
|
|
505
|
+
start_part = content[:keep_start]
|
|
506
|
+
end_part = content[-keep_end:] if keep_end > 0 else ""
|
|
507
|
+
return start_part + "\n\n[... content truncated ...]\n\n" + end_part, True
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def transcribe_pdf(path: Path) -> str:
|
|
511
|
+
"""Extract text from a PDF file."""
|
|
512
|
+
text_parts: list[str] = []
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
import fitz # type: ignore
|
|
516
|
+
|
|
517
|
+
doc = fitz.open(str(path))
|
|
518
|
+
for page_num in range(len(doc)):
|
|
519
|
+
page = doc[page_num]
|
|
520
|
+
text_parts.append(page.get_text())
|
|
521
|
+
doc.close()
|
|
522
|
+
return "\n\n".join(text_parts)
|
|
523
|
+
except ImportError:
|
|
524
|
+
pass
|
|
525
|
+
except Exception as e:
|
|
526
|
+
logger.warning("PyMuPDF failed for %s: %s", path, e)
|
|
527
|
+
|
|
528
|
+
try:
|
|
529
|
+
import pdfplumber # type: ignore
|
|
530
|
+
|
|
531
|
+
with pdfplumber.open(str(path)) as pdf:
|
|
532
|
+
for page in pdf.pages:
|
|
533
|
+
text = page.extract_text()
|
|
534
|
+
if text:
|
|
535
|
+
text_parts.append(text)
|
|
536
|
+
return "\n\n".join(text_parts)
|
|
537
|
+
except ImportError:
|
|
538
|
+
pass
|
|
539
|
+
except Exception as e:
|
|
540
|
+
logger.warning("pdfplumber failed for %s: %s", path, e)
|
|
541
|
+
|
|
542
|
+
raise RuntimeError("PDF transcription requires PyMuPDF or pdfplumber. Install with: pip install pymupdf")
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def transcribe_epub(path: Path) -> str:
|
|
546
|
+
"""Extract text from an EPUB file."""
|
|
547
|
+
try:
|
|
548
|
+
import ebooklib # type: ignore
|
|
549
|
+
from bs4 import BeautifulSoup # type: ignore
|
|
550
|
+
from ebooklib import epub # type: ignore
|
|
551
|
+
except ImportError as err:
|
|
552
|
+
raise RuntimeError(
|
|
553
|
+
"EPUB transcription requires ebooklib and beautifulsoup4. Install with: pip install ebooklib beautifulsoup4"
|
|
554
|
+
) from err
|
|
555
|
+
|
|
556
|
+
book = epub.read_epub(str(path))
|
|
557
|
+
text_parts: list[str] = []
|
|
558
|
+
|
|
559
|
+
for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
|
560
|
+
soup = BeautifulSoup(item.get_content(), "html.parser")
|
|
561
|
+
text = soup.get_text(separator="\n", strip=True)
|
|
562
|
+
if text:
|
|
563
|
+
text_parts.append(text)
|
|
564
|
+
|
|
565
|
+
return "\n\n".join(text_parts)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def process_file_input(file_path: str | Path, n_ctx: int | None = None) -> FileInput:
|
|
569
|
+
"""Process a file input: validate, read, compact, transcribe if needed."""
|
|
570
|
+
file_input = validate_file_input(file_path)
|
|
571
|
+
if file_input.error:
|
|
572
|
+
return file_input
|
|
573
|
+
|
|
574
|
+
path = Path(file_path)
|
|
575
|
+
|
|
576
|
+
try:
|
|
577
|
+
if file_input.is_text_based:
|
|
578
|
+
content = read_text_file(path)
|
|
579
|
+
elif file_input.extension.lower() == ".pdf":
|
|
580
|
+
content = transcribe_pdf(path)
|
|
581
|
+
elif file_input.extension.lower() == ".epub":
|
|
582
|
+
content = transcribe_epub(path)
|
|
583
|
+
else:
|
|
584
|
+
return file_input.model_copy(update={"error": f"Unhandled file type: {file_input.extension}"})
|
|
585
|
+
|
|
586
|
+
compacted_content, was_truncated = compact_text(content, n_ctx=n_ctx)
|
|
587
|
+
|
|
588
|
+
logger.info(
|
|
589
|
+
"Processed file: %s (%d chars, compacted=%s, truncated=%s)",
|
|
590
|
+
path.name,
|
|
591
|
+
len(content),
|
|
592
|
+
True,
|
|
593
|
+
was_truncated,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
return file_input.model_copy(
|
|
597
|
+
update={
|
|
598
|
+
"content": compacted_content,
|
|
599
|
+
"compacted": True,
|
|
600
|
+
"truncated": was_truncated,
|
|
601
|
+
}
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
except Exception as e:
|
|
605
|
+
logger.exception("File processing failed for %s", path)
|
|
606
|
+
return file_input.model_copy(update={"error": f"Failed to process file: {e}"})
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def process_multiple_files(
|
|
610
|
+
file_paths: list[str | Path],
|
|
611
|
+
n_ctx: int | None = None,
|
|
612
|
+
) -> list[FileInput]:
|
|
613
|
+
"""Process multiple file inputs, returning only successfully processed ones."""
|
|
614
|
+
results: list[FileInput] = []
|
|
615
|
+
for fp in file_paths:
|
|
616
|
+
file_input = process_file_input(fp, n_ctx=n_ctx)
|
|
617
|
+
if file_input.error:
|
|
618
|
+
logger.warning("Skipping file %s: %s", fp, file_input.error)
|
|
619
|
+
else:
|
|
620
|
+
results.append(file_input)
|
|
621
|
+
return results
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# ---------------------------------------------------------------------------
|
|
625
|
+
# Harness execution helpers
|
|
626
|
+
# ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def validate_result_quality(
|
|
630
|
+
tool_name: str,
|
|
631
|
+
result: str,
|
|
632
|
+
expected_type: str | None = None,
|
|
633
|
+
) -> tuple[bool, list[str]]:
|
|
634
|
+
"""Validate result quality after tool execution.
|
|
635
|
+
|
|
636
|
+
Returns (is_valid, issues). Issues is a list of quality concerns.
|
|
637
|
+
"""
|
|
638
|
+
issues: list[str] = []
|
|
639
|
+
|
|
640
|
+
if not result or result.strip() == "":
|
|
641
|
+
issues.append("Empty result returned")
|
|
642
|
+
return False, issues
|
|
643
|
+
|
|
644
|
+
if result.startswith(("Error ", "Tool execution")):
|
|
645
|
+
issues.append("Result indicates execution error")
|
|
646
|
+
return False, issues
|
|
647
|
+
|
|
648
|
+
if expected_type == "json":
|
|
649
|
+
try:
|
|
650
|
+
json.loads(result)
|
|
651
|
+
except json.JSONDecodeError:
|
|
652
|
+
issues.append("Expected JSON result but got invalid JSON")
|
|
653
|
+
return False, issues
|
|
654
|
+
|
|
655
|
+
if expected_type == "audio" and "audio_file" not in result and "bytes" not in result:
|
|
656
|
+
issues.append("Expected audio file metadata but got unexpected format")
|
|
657
|
+
return False, issues
|
|
658
|
+
|
|
659
|
+
return len(issues) == 0, issues
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def format_audit_trail(harness: HarnessState) -> str:
|
|
663
|
+
"""Format the execution audit trail as a readable summary."""
|
|
664
|
+
lines = [
|
|
665
|
+
"=== Harness Audit Trail ===",
|
|
666
|
+
f"Session: {harness.session_id}",
|
|
667
|
+
f"Started: {harness.started_at}",
|
|
668
|
+
f"Tool calls: {harness.total_tool_calls} ({harness.successful_calls} ok, {harness.failed_calls} failed)",
|
|
669
|
+
f"Success rate: {harness.success_rate:.1%}",
|
|
670
|
+
f"Total time: {harness.total_duration_ms:.0f}ms",
|
|
671
|
+
f"Budget remaining: {harness.budget_remaining}",
|
|
672
|
+
f"Context pressure: {harness.context_pressure:.0%}",
|
|
673
|
+
"",
|
|
674
|
+
]
|
|
675
|
+
|
|
676
|
+
if harness.file_inputs:
|
|
677
|
+
lines.append("File inputs:")
|
|
678
|
+
for f in harness.file_inputs:
|
|
679
|
+
status = "OK" if not f.error else f"ERROR: {f.error}"
|
|
680
|
+
lines.append(f" - {f.filename} ({f.extension}, {f.size_bytes} bytes) [{status}]")
|
|
681
|
+
lines.append("")
|
|
682
|
+
|
|
683
|
+
if harness.execution_history:
|
|
684
|
+
lines.append("Execution history:")
|
|
685
|
+
for i, record in enumerate(harness.execution_history, 1):
|
|
686
|
+
status_marker = "ok" if record.status == "completed" else "FAIL"
|
|
687
|
+
conf = f" (conf={record.confidence:.2f})" if record.confidence else ""
|
|
688
|
+
lines.append(f" {i}. {status_marker} {record.tool_name} ({record.duration_ms:.0f}ms{conf})")
|
|
689
|
+
if record.error:
|
|
690
|
+
lines.append(f" ERROR: {record.error}")
|
|
691
|
+
|
|
692
|
+
if harness.avoided_tools:
|
|
693
|
+
lines.append("")
|
|
694
|
+
lines.append(f"Avoided tools: {', '.join(sorted(harness.avoided_tools))}")
|
|
695
|
+
|
|
696
|
+
if harness.steering_hints:
|
|
697
|
+
lines.append("")
|
|
698
|
+
lines.append("Steering hints:")
|
|
699
|
+
for h in harness.steering_hints:
|
|
700
|
+
lines.append(f" -> {h}")
|
|
701
|
+
|
|
702
|
+
if harness.warnings:
|
|
703
|
+
lines.append("")
|
|
704
|
+
lines.append("Warnings:")
|
|
705
|
+
for w in harness.warnings:
|
|
706
|
+
lines.append(f" ! {w}")
|
|
707
|
+
|
|
708
|
+
return "\n".join(lines)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def generate_session_summary(harness: HarnessState) -> str:
|
|
712
|
+
"""Generate a concise session summary for the model/user."""
|
|
713
|
+
avoid = f", avoiding: {', '.join(sorted(harness.avoided_tools))}" if harness.avoided_tools else ""
|
|
714
|
+
return (
|
|
715
|
+
f"Session {harness.session_id}: "
|
|
716
|
+
f"{harness.total_tool_calls} calls, "
|
|
717
|
+
f"{harness.success_rate:.0%} success, "
|
|
718
|
+
f"{harness.total_duration_ms:.0f}ms total, "
|
|
719
|
+
f"budget: {harness.budget_remaining} remaining, "
|
|
720
|
+
f"pressure: {harness.context_pressure:.0%}{avoid}"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
# ---------------------------------------------------------------------------
|
|
725
|
+
# JSON Schema generation (for small model tool definitions)
|
|
726
|
+
# ---------------------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def tool_schema(
|
|
730
|
+
name: str,
|
|
731
|
+
description: str,
|
|
732
|
+
parameters: dict[str, Any],
|
|
733
|
+
) -> dict[str, Any]:
|
|
734
|
+
"""Generate an OpenAI-compatible tool schema from parameters.
|
|
735
|
+
|
|
736
|
+
Useful for generating tool definitions for small models that need
|
|
737
|
+
compact, well-structured schemas.
|
|
738
|
+
|
|
739
|
+
Example:
|
|
740
|
+
schema = tool_schema(
|
|
741
|
+
name="search_web",
|
|
742
|
+
description="Search the web for information",
|
|
743
|
+
parameters={
|
|
744
|
+
"query": {"type": "string", "description": "Search query"},
|
|
745
|
+
"max_results": {"type": "integer", "default": 5},
|
|
746
|
+
}
|
|
747
|
+
)
|
|
748
|
+
"""
|
|
749
|
+
return {
|
|
750
|
+
"type": "function",
|
|
751
|
+
"function": {
|
|
752
|
+
"name": name,
|
|
753
|
+
"description": description,
|
|
754
|
+
"parameters": {
|
|
755
|
+
"type": "object",
|
|
756
|
+
"properties": parameters,
|
|
757
|
+
"required": [k for k, v in parameters.items() if "default" not in v],
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def pydantic_tool_schema(model: type[BaseModel], name: str, description: str) -> dict[str, Any]:
|
|
764
|
+
"""Generate a tool schema from a pydantic BaseModel.
|
|
765
|
+
|
|
766
|
+
Uses the model's JSON schema to generate OpenAI-compatible tool definition.
|
|
767
|
+
|
|
768
|
+
Example:
|
|
769
|
+
class SearchArgs(BaseModel):
|
|
770
|
+
query: str = Field(description="Search query")
|
|
771
|
+
max_results: int = Field(default=5, description="Max results")
|
|
772
|
+
|
|
773
|
+
schema = pydantic_tool_schema(SearchArgs, "search_web", "Search the web")
|
|
774
|
+
"""
|
|
775
|
+
schema = model.model_json_schema()
|
|
776
|
+
# Remove pydantic-specific keys
|
|
777
|
+
for key in ("title", "$schema", "definitions"):
|
|
778
|
+
schema.pop(key, None)
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
"type": "function",
|
|
782
|
+
"function": {
|
|
783
|
+
"name": name,
|
|
784
|
+
"description": description,
|
|
785
|
+
"parameters": schema,
|
|
786
|
+
},
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
# ---------------------------------------------------------------------------
|
|
791
|
+
# Tool repair (deterministic, no LLM call)
|
|
792
|
+
# ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
from .tool_repair import (
|
|
795
|
+
repair_json,
|
|
796
|
+
coerce_types,
|
|
797
|
+
rename_keys,
|
|
798
|
+
inject_defaults,
|
|
799
|
+
repair_tool_call,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
# ---------------------------------------------------------------------------
|
|
803
|
+
# Confidence scoring
|
|
804
|
+
# ---------------------------------------------------------------------------
|
|
805
|
+
|
|
806
|
+
from .confidence import (
|
|
807
|
+
ConfidenceScore,
|
|
808
|
+
score_tool_call as score_tool_call_confidence,
|
|
809
|
+
should_escalate_to_cloud,
|
|
810
|
+
format_confidence_summary,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
# ---------------------------------------------------------------------------
|
|
814
|
+
# Progressive tool disclosure
|
|
815
|
+
# ---------------------------------------------------------------------------
|
|
816
|
+
|
|
817
|
+
from .tool_disclosure import (
|
|
818
|
+
ToolScore,
|
|
819
|
+
rank_tools,
|
|
820
|
+
get_tool_subset,
|
|
821
|
+
build_compact_tool_prompt,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
# ---------------------------------------------------------------------------
|
|
826
|
+
# Thinking model detection
|
|
827
|
+
# ---------------------------------------------------------------------------
|
|
828
|
+
|
|
829
|
+
import re as _re
|
|
830
|
+
|
|
831
|
+
NON_THINKING_MODEL_MARKERS: tuple[str, ...] = (
|
|
832
|
+
"chatterbox", "piper", "whisper", "bark", "tts-", "stt-",
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def is_thinking_model(model_id: str) -> bool:
|
|
837
|
+
"""Detect whether a model is a thinking model based on its ID.
|
|
838
|
+
|
|
839
|
+
Thinking models emit ``<think>`` blocks that would be suppressed by
|
|
840
|
+
a grammar constraint. Returns True unless the model ID matches a known
|
|
841
|
+
non-thinking model marker.
|
|
842
|
+
"""
|
|
843
|
+
lower = model_id.lower()
|
|
844
|
+
return not any(marker in lower for marker in NON_THINKING_MODEL_MARKERS)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def split_thinking(text: str) -> tuple[str, str]:
|
|
848
|
+
"""Split a model response into ``(thinking, visible)`` parts.
|
|
849
|
+
|
|
850
|
+
Extracts ``<think>...</think>`` blocks as silent reasoning. If no
|
|
851
|
+
thinking block is present the whole text is returned as visible with an
|
|
852
|
+
empty thinking part.
|
|
853
|
+
"""
|
|
854
|
+
think_pattern = _re.compile(r"<think>(.*?)</think>", _re.DOTALL)
|
|
855
|
+
thinking_parts: list[str] = []
|
|
856
|
+
last_end = 0
|
|
857
|
+
visible_parts: list[str] = []
|
|
858
|
+
|
|
859
|
+
for match in think_pattern.finditer(text):
|
|
860
|
+
before = text[last_end : match.start()]
|
|
861
|
+
if before:
|
|
862
|
+
visible_parts.append(before)
|
|
863
|
+
thinking_parts.append(match.group(1).strip())
|
|
864
|
+
last_end = match.end()
|
|
865
|
+
|
|
866
|
+
tail = text[last_end:]
|
|
867
|
+
if tail:
|
|
868
|
+
visible_parts.append(tail)
|
|
869
|
+
|
|
870
|
+
thinking = "\n\n".join(p for p in thinking_parts if p)
|
|
871
|
+
visible = "".join(visible_parts).strip()
|
|
872
|
+
if not thinking:
|
|
873
|
+
visible = text.strip()
|
|
874
|
+
return thinking, visible
|