Skip to content

Commit 38023f4

Browse files
Merge pull request #98 from open-coder-ai/w41/d4-f2-flat-decision-engine
2 parents b9ae0a8 + 5c6c9aa commit 38023f4

18 files changed

Lines changed: 237 additions & 501 deletions

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,32 @@ versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
3030
chains (demonstrated, not merely asserted).
3131

3232
### Changed
33+
- **The four `flat_decision` adapters are folded onto the same engine**
34+
(`docs/design/dialect-families.md` D4). `gemini_cli`, `tabnine`, `junie` and `grok` are
35+
no longer hand-written modules: the D3 engine executes their `data/vendors/<agent>.json`
36+
entries, which now carry the G1 renderer data (verdict `words`, `degrade_notes` verbatim
37+
from the deleted adapters, `reason_defaults`). The golden wire fixtures pass
38+
**byte-for-byte unchanged**. The devices F2 needed are all word/grammar-table
39+
extensions, each an opt-in schema field: an escalate word path in the G1 renderer, the
40+
`hook_specific_tool_input` (gemini) and `top_level_updated_input` (junie) transform
41+
bodies, template degrade notes filling (reason, wire event) slots (gemini's
42+
ask-degradation), per-gate reason defaults and allow-silence and allow-body context
43+
(junie's Stop/`additionalContext`), a reverse-map fallback for payloads carrying no wire
44+
event name (tabnine's hand-built events), a `tool_input` envelope chain (grok's
45+
`toolInput`), write-gated content (gemini), and literal hook-entry extras (tabnine's
46+
`name`). junie's `PermissionRequest` alias gate is recounted explicitly via
47+
`_EXTRA_GATE_NAMES`, same as devin's. `bundle()` now composes engine + inlined `VENDOR`
48+
literal for these four too (measured: gemini_cli 690, tabnine 664, junie 664, grok 664
49+
lines, from 458/409/429/439 — real numbers in the design doc §7). Two respond()-direct
50+
behaviours the events table cannot express did not survive the fold: tabnine no longer
51+
answers the undocumented `BeforeModel`/`AfterModel` wire names (they map to no canonical
52+
event, and the dispatcher never reached them), and gemini_cli no longer honours a
53+
rewrite at gates whose recounted capability is deny-only — both unreachable through
54+
`handle()`, so the wire through the dispatcher is unchanged.
55+
`test_the_unverified_vocabulary_is_still_only_tabnine` is retired exactly as §5's table
56+
prescribed; its intent lives on as
57+
`test_vendor_config.py::test_vocabulary_basis_is_unverified_only_for_tabnine`, a config
58+
derivation instead of a source grep.
3359
- **Four `hook_json` adapters are now one engine plus their vendor config entries**
3460
(`docs/design/dialect-families.md` D3). `claude_code`, `codex_cli`, `kimi_code` and
3561
`devin` are no longer hand-written modules: `adapters/_hook_json.py` (marker claims,

docs/design/dialect-families.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,17 @@ first, D3/D4 swap cleanly — they share only D1+D2.
321321
§4 payoff side held: the four deleted adapter modules were 624 lines; the engine that
322322
replaces them (`_hook_json` + `_hook_entry` + `_probes` + `_family`) is ~390 shared
323323
across the family.
324+
- **[v]** D4's F2 numbers, measured at its base (main `b9ae0a8`, where the pre-fold
325+
bundles stood at gemini_cli 458 / tabnine 409 / junie 429 / grok 439): gemini_cli 690,
326+
tabnine 664, junie 664, grok 664 — +51% to +62%, above both the §4 estimate (470–500)
327+
and D3's +32–44%. The overshoot is composition, not dialect: an engine bundle inlines
328+
the whole shared engine, G2 and the F1-only devices included — the trade D3 already
329+
made, and the bundle-composition wave (D6) owns any trimming. The source-tree payoff
330+
held again: the four deleted modules were 465 lines, replaced by no new engine module
331+
at all — the D3 engine absorbed F2 with ~40 lines of word/grammar-table devices
332+
(escalate word path, two transform-body variants, template notes, per-gate reason
333+
defaults, allow-body context/silence, a reverse-map wire fallback, a tool-input
334+
envelope chain, write-gated content, literal hook-entry extras).
324335
- **[v]** (was [h]) The `reject_probes: ["looks_like_claude_code"]` device (named engine
325336
predicates referenced from config) is the narrowest crack in the code/config line;
326337
if D2 finds more than ~3 named probes are needed, that is evidence the line is drawn

src/agentseam/adapters/__init__.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,19 @@
66
from . import (
77
antigravity,
88
cursor,
9-
gemini_cli,
10-
grok,
11-
junie,
12-
tabnine,
139
vscode_copilot,
1410
windsurf,
1511
)
1612
from ._family import bind
1713

18-
#: hook_json vendors driven by engine + data/vendors entry (dialect-families.md D3);
14+
#: hook_json (D3) and flat_decision (D4) vendors driven by engine + data/vendors entry;
1915
#: vscode_copilot stays a dialect module -- its three-path claims() and memory-tool
2016
#: branching are beyond what the flat config may carry (§3.1).
21-
_CONFIG_DRIVEN = ("claude_code", "codex_cli", "devin", "kimi_code")
17+
_CONFIG_DRIVEN = ("claude_code", "codex_cli", "devin", "gemini_cli", "grok", "junie", "kimi_code", "tabnine")
2218

2319
ADAPTERS = {
2420
antigravity.AGENT: antigravity,
2521
cursor.AGENT: cursor,
26-
gemini_cli.AGENT: gemini_cli,
27-
grok.AGENT: grok,
28-
junie.AGENT: junie,
29-
tabnine.AGENT: tabnine,
3022
vscode_copilot.AGENT: vscode_copilot,
3123
windsurf.AGENT: windsurf,
3224
}

src/agentseam/adapters/_hook_entry.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313

1414
def _hook_dict(cfg, command):
1515
entry = {"type": "command", "command": command}
16-
for key in cfg["hook_entry"].get("entry_extra", {}):
16+
for key, value in cfg["hook_entry"].get("entry_extra", {}).items():
1717
if key in _WINDOWS_KEYS:
1818
entry[key] = powershell_command(command)
19+
else:
20+
entry[key] = value
1921
return entry
2022

2123

src/agentseam/adapters/_hook_json.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""The F1 `hook_json` family engine: claims/parse/respond driven by a vendor config entry.
1+
"""The F1 `hook_json` / F2 `flat_decision` engine: claims/parse/respond driven by a vendor config entry.
22
33
Every function takes the vendor's `data/vendors/<agent>.json` entry as its first argument;
44
`_family.bind()` closes them over one entry, and a bundle inlines this module next to a
@@ -98,10 +98,24 @@ def _field(raw, ti, chain):
9898
return value
9999

100100

101+
#: `fields` keys that steer the extraction rather than naming an Event field.
102+
_FIELD_META = ("tool_input", "content_only_for_write_tools")
103+
104+
105+
def _tool_input_raw(cfg, raw):
106+
for key in cfg["fields"].get("tool_input", ("tool_input",)):
107+
value = raw.get(key)
108+
if value is not None:
109+
return value
110+
return None
111+
112+
101113
def hj_parse(cfg, raw):
102114
"""Normalise one payload along the entry's ordered field-fallback chains."""
103-
ti = tool_input_of(raw.get("tool_input"))
104-
fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items()}
115+
ti = tool_input_of(_tool_input_raw(cfg, raw))
116+
fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META}
117+
if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()):
118+
fields["content"] = None
105119
if isinstance(fields.get("output"), (dict, list)):
106120
fields["output"] = _json.dumps(fields["output"])
107121
return Event(
@@ -147,17 +161,23 @@ def _note_for(v, decision, at_gate, missing_input):
147161
return None
148162

149163

150-
def _default_for(v, decision, at_gate):
164+
def _default_for(v, decision, at_gate, wire=None):
165+
gate_defaults = v.get("gate_reason_defaults", {})
166+
if wire in gate_defaults:
167+
return gate_defaults[wire]
151168
defaults = v.get("reason_defaults", {})
152169
key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny")
153170
if at_gate and key + "_gate" in defaults:
154171
return defaults[key + "_gate"]
155172
return defaults.get(key, "blocked by policy")
156173

157174

158-
def _refusal_text(v, decision, at_gate):
175+
def _refusal_text(v, decision, at_gate, wire=None):
159176
note = _note_for(v, decision, at_gate, decision.updated_input is None)
160-
default = _default_for(v, decision, at_gate)
177+
default = _default_for(v, decision, at_gate, wire)
178+
if note and "%s" in note:
179+
# A template note fills (the reason or its default, the wire event name) itself.
180+
return note % (decision.reason or default, wire)
161181
reason = decision.reason
162182
if v.get("note_style") == "suffix":
163183
reason = reason or default
@@ -174,12 +194,30 @@ def _g1(v, gate, decision, wire, name):
174194
value = _context_value(v, decision)
175195
if at_context_event and value:
176196
return _context_body(name, value)
197+
if wire in v.get("allow_silent_events", ()):
198+
return "", 0
177199
if "allow" in words:
178-
return _json.dumps({"decision": words["allow"]}), 0
200+
out = {"decision": words["allow"]}
201+
if v.get("allow_context_key") and decision.outcome == ALLOW and value:
202+
out[v["allow_context_key"]] = value
203+
return _json.dumps(out), 0
179204
return "", 0
180-
if decision.outcome == TRANSFORM and gate["honours_transform"] and decision.updated_input is not None:
181-
return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0
182-
out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False)}
205+
if decision.outcome == TRANSFORM and gate["honours_transform"]:
206+
if v.get("transform_grammar") == "hook_specific_tool_input":
207+
return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0
208+
if decision.updated_input is not None:
209+
if v.get("transform_grammar") == "top_level_updated_input":
210+
out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input}
211+
if decision.reason:
212+
out["reason"] = decision.reason
213+
return _json.dumps(out), 0
214+
return _json.dumps(
215+
{"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}
216+
), 0
217+
if decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words:
218+
reason = decision.reason or _default_for(v, decision, True, wire)
219+
return _json.dumps({"decision": words["escalate"], "reason": reason}), 0
220+
out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)}
183221
if at_context_event and v.get("context_source") == "context" and decision.context:
184222
out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context}
185223
return _json.dumps(out), 0
@@ -221,6 +259,8 @@ def hj_respond(cfg, decision, event):
221259
wire = _wire_name(cfg, event.raw or {})
222260
if wire is None:
223261
wire = v.get("default_wire_event")
262+
if wire is None and v.get("missing_wire") == "reverse_map":
263+
wire = hj_reverse(cfg).get(event.event)
224264
name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse")
225265
gate = v["gates"].get(wire)
226266
if gate is None:

src/agentseam/adapters/gemini_cli.py

Lines changed: 0 additions & 120 deletions
This file was deleted.

0 commit comments

Comments
 (0)