---
name: tool-outcome-classify
description: Engine substrate that classifies tool results into ok / failed / zero_effect / blocked / partial for the decision log
title: Tool Outcome Classifier
category: _meta
status: active
metadata:
  priority: 9
---
## Instructions

This is a `_meta` skill — an engine-level substrate, not a behavioral skill. It is not injected into any agent's system prompt. Its job is to ship the strategy half of the tool-outcome classifier that records every `_execute_tool` call in the decision log.

The engine is [`activity/decisions.py:classify_tool_result`](../../../../server/src/agntspace/activity/decisions.py). It handles the STRUCTURAL cases:

- `result` is not a dict → `("failed", "non_dict_result")`
- `result["error"]` is a string → `("failed", <parsed ErrorType or "error">)`
- `result["contract_blocked"]` → `("blocked", "contract_block")`
- `result["trust_blocked"]` → `("blocked", "trust_block")`
- Default → `("ok", "")`

These are deterministic. Reasonable people don't disagree on "a dict with an `error` key is a failure."

The STRATEGIC question — "did this tool actually do something useful?" — is per-tool and lives in `config/outcomes.yaml`. The engine reads that YAML once at startup, passes the `yaml_rules` dict to every classifier call, and the classifier checks whether ANY rule matched for `zero_effect` or `partial` status.

### Rule format

```yaml
zero_effect:
  <tool_name>:
    - field: <key>
      op: eq | ne | lt | le | gt | ge | empty | nonempty
      value: <expected>
    - field: <key>
      op: ...
      value: ...
partial:
  <tool_name>:
    - field: ...
      op: ...
      value: ...
```

Semantics:
- ALL rules in a tool's list must match for that status to apply.
- `zero_effect` is checked before `partial`. First match wins.
- Missing fields are treated as `None` — use `op: empty` to match "no value or zero or empty".

### What counts as zero-effect

"The tool ran without raising but produced no observable effect." Examples:
- `run_wiki_job` with `ingested: 0, compiled: 0, articles: 0`
- `research_scrape` with `scraped: 0, pages: []`
- `web_search` with `results: []`
- `create_kb` where the KB already existed (handled by `existed: true` flag)

Adding a new rule for a tool does NOT require a code change. Users add the rule, restart the server (which re-reads the skill config), and the next decision row for that tool reflects the new classification.

### What counts as partial

"The tool accomplished some of its intended work but not all of it." Example:
- `research_scrape` with `requested: 5, scraped: 2, failed: 3`

Partial is a weaker signal than `zero_effect` but stronger than `failed` — the caller should see "we got 2/5, here's what worked, here's what didn't".

## Rules

- Never encode per-tool classification logic in Python. If you find yourself writing `if tool_name == "run_wiki_job":` in `classify_tool_result`, stop and add the rule here instead.
- Every tool listed in the registry that CAN return "structurally successful but zero work" SHOULD have an entry here. Audit quarterly.
- Rules must be additive — if a tool's behavior changes (e.g., a new "articles: 0" success case for idempotent re-runs), update the rule, don't work around it.

## Tone

Terse. This is a config file, not a behavior doc.

## Examples

**Rule fires**: `run_wiki_job` returns `{"ingested": 0, "compiled": 0, "articles": 0, "issues": 0}` → classifier returns `("zero_effect", "empty_result")` → decision log shows `result_status=zero_effect`. The user can filter `/decisions?result_status=zero_effect` to see every tool that claimed success but did nothing.

**Rule does not fire**: `run_wiki_job` returns `{"ingested": 3, "compiled": 1, "articles": 1}` → no rule matches (ingested != 0) → classifier returns `("ok", "")`.