# Tool Call Output Metadata — Implementation Guide

## What changed and why

Tool results can be large (100k+ chars of JSON). Until now the frontend had no
way to know *how big* a result was, or *what kind of data* it contained, without
loading the full `cx_tool_call.output` column.

Two new fields fix this:

| Field | Type | Where stored | Description |
|-------|------|-------------|-------------|
| `output_chars` | `INTEGER NOT NULL DEFAULT 0` | `cx_tool_call` + `cx_message.content` pointer block | True character count of the serialized output (pre-truncation). `0` = null/empty or pre-migration row. |
| `output_preview` | `JSONB` (nullable) | `cx_tool_call` + `cx_message.content` pointer block | Lightweight structured hint for UI rendering (≤ ~500 chars of JSON). See preview format below. |

---

## Database migration

Run the SQL file before deploying:

```
packages/matrx-ai/matrx_ai/db/migrations/add_tool_call_output_metadata.sql
```

It adds both columns to `cx_tool_call` with `ALTER TABLE … ADD COLUMN IF NOT EXISTS`
so it is idempotent and safe to re-run.

---

## How `output_preview` is structured

### Auto-synthesized (default)

The logger inspects the raw output and builds a default preview:

| Output type | Preview |
|-------------|---------|
| `None` / empty | `{"chars": 0}` |
| Small JSON dict (≤ 20 keys AND ≤ 500 chars serialized) | The dict itself |
| Large JSON dict | `{"keys": ["key1", "key2", ...], "chars": N}` |
| JSON list | `{"count": N, "chars": N}` |
| Text | `{"chars": N}` |

### Tool-supplied (optional)

A tool can return a richer preview by setting `output_preview` on its `ToolResult`:

```python
return ToolResult(
    success=True,
    output=big_result_dict,
    output_preview={               # ← tool-supplied, always wins
        "pages_scraped": 5,
        "top_url": "https://...",
        "query": "search term",
    },
)
```

Rules:
- Keep it small — target ≤ 20 keys and ≤ 500 serialized chars.
- Use human-readable keys.
- Do NOT include the full output or large nested structures.
- Numbers, short strings, and small lists of primitives are ideal.

---

## How data flows end-to-end

```
Tool function returns ToolResult
        │
        ▼
executor.py: execution_logger.prepare_metadata(result)
  ├── calls _serialize_output()  → sets result.output_chars (synchronous)
  └── calls _synthesize_preview() → sets result.output_preview (synchronous)
        │
        ▼
executor.py: result.to_tool_result_content()
  └── dict includes output_chars, output_preview
        │
        ▼
Passed into UnifiedMessage(role='tool', content=[ToolResultContent, ...])
        │
        ▼
asyncio.create_task(execution_logger.log_completed(row_id, result))
  └── writes output, output_chars, output_preview → cx_tool_call (UPDATE)
        │                                            (happens asynchronously)
        ▼
persist_completed_request() writes cx_message with content=[]  ← initially empty
        │
        ▼
_backfill_tool_message() reads output_chars / output_preview
  from the in-memory ToolResultContent objects (already set synchronously above)
  └── builds pointer blocks:
      {
        "type": "tool_result",
        "call_id": "...",
        "tool_use_id": "...",      ← same value as call_id (existing system pattern)
        "name": "tool_name",
        "is_error": false,
        "output_chars": 42830,
        "output_preview": {"keys": ["title", "body"], "chars": 42830}
      }
        │
        ▼
validate_message_content(pointer_blocks)  ← Pydantic ToolResultPart
        │
        ▼
cxm.message.update_cx_message(msg_id, content=validated_blocks)
        │
        ▼
cx_message.content is now a real pointer block array — never empty again
```

---

## Reading tool results in the frontend

### From `cx_message.content` (fast path — no extra query)

```typescript
// pointer block — always available
type ToolResultPart = {
  type: "tool_result";
  call_id: string;
  tool_use_id: string;
  name: string;
  is_error: boolean;
  output_chars: number;           // 0 = null/empty or pre-migration
  output_preview?: Record<string, unknown>;  // absent on very old rows
};
```

Use `output_chars` to drive a size badge:

```typescript
function sizeLabel(chars: number): string {
  if (chars === 0) return "";
  if (chars < 1_000) return "tiny";
  if (chars < 10_000) return "small";
  if (chars < 100_000) return "medium";
  return "large";
}
```

Use `output_preview` to render a compact summary without loading the full result.

### From `cx_tool_call.output` (full data — load on demand)

```sql
SELECT output, output_type, output_chars, output_preview
FROM cx_tool_call
WHERE call_id = $1 AND conversation_id = $2
LIMIT 1;
```

Load this only when the user actually wants to inspect the full tool output.

---

## Conversation rebuild (multi-turn history)

When a conversation is reloaded for a follow-up LLM turn,
`rebuild_conversation_messages()` in `db/conversation_rebuild.py` reads
`cx_tool_call.output` directly for all `role='tool'` messages. The pointer
block in `cx_message.content` is used by the frontend only — it is never
sent to the LLM.

---

## Adding `output_preview` to an existing tool

```python
# In your tool's execute() method, before returning:
return ToolResult(
    success=True,
    output=result_data,
    # Tool-supplied preview (optional — logger synthesizes one if absent)
    output_preview={
        "rows_returned": len(result_data.get("rows", [])),
        "table": "users",
        "filter": arguments.get("filter_field"),
    },
)
```

That's it. The rest of the pipeline picks it up automatically.

---

## Pre-migration rows

Rows inserted before the migration have `output_chars = 0` and
`output_preview = NULL`. The frontend should treat `output_chars = 0` as
"unknown size" (not as "empty result") and show no size badge rather than
a "tiny" badge.

---

## Pending tasks

### 1. Regenerate TypeScript types (required after deploying this feature)

The `ToolResultPart` TypeScript interface is auto-generated from the Pydantic model.
After deploying, fetch the updated types from the running server:

```
GET /schema/bundle/stream-events-ts
```

Or regenerate locally (requires server or direct import):

```bash
uv run python scripts/generate_types.py stream
```

The new interface will include:

```typescript
export interface ToolResultPart {
  type: "tool_result";
  call_id?: string;
  tool_use_id?: string;
  name?: string;
  is_error?: boolean;
  output_chars?: number;
  output_preview?: Record<string, unknown> | null;
  metadata?: Record<string, unknown>;
}
```

### 2. Frontend rendering (optional — can be done incrementally)

Use `output_chars` for size badges and `output_preview` for compact summaries
in tool result cards. See the "Reading tool results in the frontend" section above.
No breaking changes — existing code that ignores these fields continues to work.

---

## Files changed

| File | Change |
|------|--------|
| `tools/models.py` | `ToolResult` + `CxToolCallRecord` — new `output_chars`, `output_preview` fields |
| `tools/logger.py` | `prepare_metadata()` (sync), `_serialize_output` returns 3-tuple, `_synthesize_preview()`, `log_completed` writes both fields to DB, `log_error` stamps in-memory object |
| `tools/executor.py` | Calls `prepare_metadata(result)` synchronously before `create_task` |
| `config/tools_config.py` | `ToolResultContent` — new `output_chars`, `output_preview` fields; docstring clarification on `to_storage_dict()` |
| `config/unified_content.py` | `reconstruct_content` — reads `output_chars`/`output_preview` from stored pointer blocks; compatible with legacy rows |
| `db/message_parts.py` | `ToolResultPart` — replaced `content: str` with `output_chars: int`, `output_preview: dict | None`; added `tool_use_id` alongside `call_id` |
| `db/persistence.py` | `_backfill_tool_message` — reads output metadata from in-memory `ToolResultContent`; builds and validates pointer blocks; updates `cx_message.content` |
| `db/migrations/add_tool_call_output_metadata.sql` | Schema migration |
