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.
@@ -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