File size: 7,694 Bytes
26d4279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
"""The JSON action protocol the agent uses instead of provider-native tool calling.

WHY NOT NATIVE TOOL CALLING: RAGConfig.attempts() falls back PER CALL (groq ->
gemini -> extractive). An agent run makes several sequential calls carrying an
accumulating transcript. With native tools that transcript is a provider-specific
object graph — OpenAI `tool_calls`, Gemini `functionCall`, Anthropic `tool_use`
blocks — so a mid-run fallback would mean translating an in-flight tool history
between three schemas, on exactly the rate-limit path that is hardest to test.
Here the transcript is just text, so any provider can resume at any step.

The cost is that we must parse model prose robustly. Everything below exists to
make that never raise: a lenient extractor, then a validator, then a coercion to
`{"action": "answer"}` so a bad reply ends the turn gracefully instead of 500ing.
"""
from __future__ import annotations

import json
import logging
import re
from typing import Any

logger = logging.getLogger("inkference.agent")

# Keep this tiny. Every extra action is another thing a small model gets wrong.
ACTIONS: dict[str, dict[str, Any]] = {
    "search": {
        "args": {"query": str, "k": int},
        "required": ("query",),
        "doc": 'Semantic search over the whole journal. {"action":"search","query":"arrival at Plymouth","k":6}',
    },
    "read_page": {
        "args": {"page": int},
        "required": ("page",),
        "doc": 'Read one full page by its corpus page number. {"action":"read_page","page":118}',
    },
    "read_range": {
        "args": {"start": int, "end": int},
        "required": ("start", "end"),
        "doc": (
            'Read consecutive pages in order — use this for "what happened next" '
            'questions. {"action":"read_range","start":118,"end":123}'
        ),
    },
    "overview": {
        "args": {},
        "required": (),
        "doc": 'Document title, page count, and which page range each book covers. {"action":"overview"}',
    },
    "answer": {
        "args": {"why": str},
        "required": (),
        "doc": 'Stop searching and write the answer from the evidence gathered. {"action":"answer"}',
    },
}

TERMINAL = "answer"

REPAIR_PROMPT = (
    "Your last reply was not a valid action. Reply with ONLY a single JSON object "
    "and nothing else — no explanation, no markdown fence."
)


def render_tool_catalog(actions: list[str] | None = None) -> str:
    """The action menu injected into the plan system prompt."""
    names = actions or list(ACTIONS)
    return "\n".join(f"- {ACTIONS[n]['doc']}" for n in names if n in ACTIONS)


# --------------------------------------------------------------------------- #
# extraction
# --------------------------------------------------------------------------- #
_FENCE_RE = re.compile(r"```(?:json)?\s*(.+?)\s*```", re.DOTALL | re.IGNORECASE)


def _first_balanced_object(text: str) -> str | None:
    """First {...} with balanced braces, ignoring braces inside strings.

    Cheaper and more predictable than a regex for nested objects, and it survives
    the common failure of a model wrapping JSON in a sentence.
    """
    start = text.find("{")
    while start != -1:
        depth = 0
        in_str = False
        esc = False
        for i in range(start, len(text)):
            ch = text[i]
            if esc:
                esc = False
                continue
            if ch == "\\" and in_str:
                esc = True
                continue
            if ch == '"':
                in_str = not in_str
                continue
            if in_str:
                continue
            if ch == "{":
                depth += 1
            elif ch == "}":
                depth -= 1
                if depth == 0:
                    return text[start : i + 1]
        start = text.find("{", start + 1)
    return None


# Repairs for the JSON errors small models actually make, applied in order and
# cumulatively. Each one was added in response to a real observed failure — resist
# adding speculative ones, since a repair that "fixes" valid JSON is worse than a
# parse failure (which costs one retry, not a wrong action).
_REPAIRS = (
    # Trailing comma before a closer: {"a":1,}
    (re.compile(r",\s*([}\]])"), r"\1"),
    # Stray quote after a number: {"end":26"}  — observed from gpt-oss-120b.
    (re.compile(r'(:\s*-?\d+(?:\.\d+)?)"+\s*(?=[,}\]])'), r"\1"),
)


def _loads_lenient(blob: str) -> dict | None:
    candidate = blob
    try:
        obj = json.loads(candidate)
    except json.JSONDecodeError:
        obj = None
        for pattern, repl in _REPAIRS:
            candidate = pattern.sub(repl, candidate)
            try:
                obj = json.loads(candidate)
                break
            except json.JSONDecodeError:
                continue
        if obj is None:
            return None
    return obj if isinstance(obj, dict) else None


def extract_json(text: str) -> dict | None:
    """Pull one JSON object out of a model reply. None if there isn't one."""
    if not text:
        return None
    fenced = _FENCE_RE.search(text)
    for candidate in (fenced.group(1) if fenced else None, text.strip(),
                      _first_balanced_object(text)):
        if candidate and (obj := _loads_lenient(candidate)) is not None:
            return obj
    return None


# --------------------------------------------------------------------------- #
# validation
# --------------------------------------------------------------------------- #
def _coerce_int(value: Any) -> int | None:
    if isinstance(value, bool):
        return None
    if isinstance(value, int):
        return value
    if isinstance(value, float) and value.is_integer():
        return int(value)
    if isinstance(value, str):
        m = re.search(r"-?\d+", value)
        if m:
            return int(m.group())
    return None


def validate(obj: dict) -> dict:
    """Normalise a parsed object into a known action.

    Never raises: an unknown action or missing required arg degrades to `answer`,
    which lets the graph compose from whatever evidence it already holds.
    """
    name = str(obj.get("action", "")).strip().lower()
    if name not in ACTIONS:
        logger.debug("agent: unknown action %r -> answer", name)
        return {"action": TERMINAL, "why": f"unrecognised action {name!r}"}

    spec = ACTIONS[name]
    out: dict[str, Any] = {"action": name}
    for arg, typ in spec["args"].items():
        if arg not in obj or obj[arg] is None:
            continue
        if typ is int:
            coerced = _coerce_int(obj[arg])
            if coerced is not None:
                out[arg] = coerced
        else:
            out[arg] = str(obj[arg]).strip()

    missing = [a for a in spec["required"] if a not in out]
    if missing:
        logger.debug("agent: action %r missing %s -> answer", name, missing)
        return {"action": TERMINAL, "why": f"action {name!r} missing {missing}"}
    return out


def parse_action(text: str) -> tuple[dict | None, dict | None]:
    """Parse a plan reply.

    Returns (action, extras). `action` is None only when nothing JSON-shaped was
    found — the caller may then spend one repair retry. `extras` carries non-action
    keys the plan prompt asks for on the first turn (notably standalone_question).
    """
    obj = extract_json(text)
    if obj is None:
        return None, None
    action = validate(obj)
    extras = {}
    if isinstance(obj.get("standalone_question"), str):
        sq = obj["standalone_question"].strip()
        if sq:
            extras["standalone_question"] = sq
    return action, extras