honestcode 0.5.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.
- honestcode/__init__.py +19 -0
- honestcode/cli.py +428 -0
- honestcode/graph/__init__.py +13 -0
- honestcode/graph/graph_store.py +276 -0
- honestcode/graph/watcher.py +116 -0
- honestcode/guards/__init__.py +104 -0
- honestcode/guards/guard.py +777 -0
- honestcode/guards/store.py +457 -0
- honestcode/guards/taxonomy.py +228 -0
- honestcode/honest/__init__.py +19 -0
- honestcode/honest/project_binding.py +200 -0
- honestcode/honest/router.py +205 -0
- honestcode/honest/symbol_index.py +446 -0
- honestcode/integrations/__init__.py +14 -0
- honestcode/integrations/pydantic_ai.py +304 -0
- honestcode/mcp/__init__.py +46 -0
- honestcode/mcp/knowledge_base.py +290 -0
- honestcode/mcp/server.py +357 -0
- honestcode/mcp/tools.py +2002 -0
- honestcode/py.typed +0 -0
- honestcode/sandbox/__init__.py +334 -0
- honestcode/structure/__init__.py +6 -0
- honestcode/structure/extractor.py +353 -0
- honestcode/structure/multi_lang.py +267 -0
- honestcode/structure/relations.py +47 -0
- honestcode/structure/utils.py +262 -0
- honestcode/verify/__init__.py +19 -0
- honestcode/verify/evidence.py +195 -0
- honestcode/verify/surface.py +497 -0
- honestcode/verify/verify.py +1074 -0
- honestcode-0.5.0.dist-info/METADATA +669 -0
- honestcode-0.5.0.dist-info/RECORD +36 -0
- honestcode-0.5.0.dist-info/WHEEL +5 -0
- honestcode-0.5.0.dist-info/entry_points.txt +3 -0
- honestcode-0.5.0.dist-info/licenses/LICENSE +21 -0
- honestcode-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Durable store for side-effect records.
|
|
2
|
+
|
|
3
|
+
The upstream implementation kept its records in a ``Vec``. An idempotency key
|
|
4
|
+
that vanishes when the process exits is not an idempotency key: the crash that
|
|
5
|
+
loses the record is the same crash after which the agent retries the effect.
|
|
6
|
+
This store is the fix, and everything else in this package is deliberately thin
|
|
7
|
+
around it.
|
|
8
|
+
|
|
9
|
+
The uniqueness constraint is the whole point and is enforced by the database,
|
|
10
|
+
not by a read-then-write check in Python:
|
|
11
|
+
|
|
12
|
+
UNIQUE (session_id, idempotency_key)
|
|
13
|
+
|
|
14
|
+
A single connection guarded by a lock is used rather than a pool, because the
|
|
15
|
+
guard is called on the agent's tool-call path where a lock is cheaper than
|
|
16
|
+
contention and because SQLite serialises writers regardless.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sqlite3
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from honestcode.guards.taxonomy import EffectClass
|
|
31
|
+
|
|
32
|
+
__all__ = ["EffectRecord", "EffectStatus", "EffectStore", "StoreError"]
|
|
33
|
+
|
|
34
|
+
_SCHEMA = """
|
|
35
|
+
CREATE TABLE IF NOT EXISTS side_effects (
|
|
36
|
+
effect_id TEXT PRIMARY KEY,
|
|
37
|
+
session_id TEXT NOT NULL,
|
|
38
|
+
idempotency_key TEXT NOT NULL,
|
|
39
|
+
action_type TEXT NOT NULL,
|
|
40
|
+
target TEXT NOT NULL DEFAULT '',
|
|
41
|
+
effect_class TEXT NOT NULL,
|
|
42
|
+
payload_hash TEXT NOT NULL DEFAULT '',
|
|
43
|
+
preconditions TEXT NOT NULL DEFAULT '[]',
|
|
44
|
+
status TEXT NOT NULL,
|
|
45
|
+
scope TEXT,
|
|
46
|
+
response_hash TEXT,
|
|
47
|
+
compensation TEXT,
|
|
48
|
+
created_at INTEGER NOT NULL,
|
|
49
|
+
updated_at INTEGER NOT NULL,
|
|
50
|
+
committed_at INTEGER,
|
|
51
|
+
UNIQUE (session_id, idempotency_key)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_side_effects_status
|
|
55
|
+
ON side_effects (status);
|
|
56
|
+
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_side_effects_session
|
|
58
|
+
ON side_effects (session_id, created_at);
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class StoreError(RuntimeError):
|
|
63
|
+
"""The store could not be read or written."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class EffectStatus(str, Enum):
|
|
67
|
+
"""Lifecycle states, as a ``str`` enum so they survive JSON round-trips.
|
|
68
|
+
|
|
69
|
+
The set is deliberately larger than upstream's ``PENDING/COMMITTED/
|
|
70
|
+
COMPENSATED/FAILED``. ``EXECUTING`` is the state that makes recovery sound:
|
|
71
|
+
an effect is marked before the outside world is touched, so a process that
|
|
72
|
+
dies mid-call leaves evidence that the outcome is *unknown* rather than
|
|
73
|
+
evidence that nothing happened. Without it, a crash between "call succeeded"
|
|
74
|
+
and "record committed" is indistinguishable from "never called", and the
|
|
75
|
+
agent retries an external mutation.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
PENDING = "pending"
|
|
79
|
+
"""Intent recorded; the effect has not been attempted yet."""
|
|
80
|
+
|
|
81
|
+
EXECUTING = "executing"
|
|
82
|
+
"""The effect may have reached the outside world. Outcome unknown until the
|
|
83
|
+
record advances — this is the state a crash leaves behind."""
|
|
84
|
+
|
|
85
|
+
COMMITTED = "committed"
|
|
86
|
+
"""The effect ran and its result is recorded. Never re-run; serve the receipt."""
|
|
87
|
+
|
|
88
|
+
FAILED = "failed"
|
|
89
|
+
"""The effect raised. Whether it partially applied is unknown; the class
|
|
90
|
+
decides whether retrying is safe."""
|
|
91
|
+
|
|
92
|
+
COMPENSATED = "compensated"
|
|
93
|
+
"""A reversible effect was undone. Safe to attempt again."""
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def bare(self) -> str:
|
|
97
|
+
return self.value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Historical module-level names; guard.py, __init__.py and tests import these,
|
|
101
|
+
# so the enum members are re-exported under the same identities.
|
|
102
|
+
PENDING = EffectStatus.PENDING
|
|
103
|
+
EXECUTING = EffectStatus.EXECUTING
|
|
104
|
+
COMMITTED = EffectStatus.COMMITTED
|
|
105
|
+
FAILED = EffectStatus.FAILED
|
|
106
|
+
COMPENSATED = EffectStatus.COMPENSATED
|
|
107
|
+
|
|
108
|
+
_ALL_STATUSES = frozenset(EffectStatus)
|
|
109
|
+
|
|
110
|
+
#: Statuses where re-running the effect would be a second real-world event.
|
|
111
|
+
ACTIVE_STATUSES = frozenset({PENDING, EXECUTING, COMMITTED})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class EffectRecord:
|
|
116
|
+
"""One side effect the agent intended, and how far it got."""
|
|
117
|
+
|
|
118
|
+
effect_id: str
|
|
119
|
+
session_id: str
|
|
120
|
+
idempotency_key: str
|
|
121
|
+
action_type: str
|
|
122
|
+
target: str
|
|
123
|
+
effect_class: EffectClass
|
|
124
|
+
payload_hash: str
|
|
125
|
+
preconditions: tuple[str, ...]
|
|
126
|
+
status: EffectStatus
|
|
127
|
+
#: The durable scope the caller supplied, if any. Scoped records live in a
|
|
128
|
+
#: namespace derived from this value; keeping it on the row is what lets
|
|
129
|
+
#: recovery query the records for a run id after a restart.
|
|
130
|
+
scope: str | None = None
|
|
131
|
+
response_hash: str | None = None
|
|
132
|
+
compensation: dict[str, Any] | None = None
|
|
133
|
+
created_at: int = 0
|
|
134
|
+
updated_at: int = 0
|
|
135
|
+
committed_at: int | None = None
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def is_active(self) -> bool:
|
|
139
|
+
"""True when re-issuing this intent must not run the effect again."""
|
|
140
|
+
return self.status in ACTIVE_STATUSES
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def outcome_known(self) -> bool:
|
|
144
|
+
"""True when the record states definitively whether the effect ran."""
|
|
145
|
+
return self.status in (COMMITTED, COMPENSATED)
|
|
146
|
+
|
|
147
|
+
def to_dict(self) -> dict[str, Any]:
|
|
148
|
+
"""The receipt shape handed back to a caller."""
|
|
149
|
+
out: dict[str, Any] = {
|
|
150
|
+
"effect_id": self.effect_id,
|
|
151
|
+
"session_id": self.session_id,
|
|
152
|
+
"idempotency_key": self.idempotency_key,
|
|
153
|
+
"action_type": self.action_type,
|
|
154
|
+
"target": self.target,
|
|
155
|
+
"effect_class": self.effect_class.bare,
|
|
156
|
+
"status": self.status.bare,
|
|
157
|
+
"created_at": self.created_at,
|
|
158
|
+
"updated_at": self.updated_at,
|
|
159
|
+
}
|
|
160
|
+
if self.scope is not None:
|
|
161
|
+
out["scope"] = self.scope
|
|
162
|
+
if self.response_hash is not None:
|
|
163
|
+
out["response_hash"] = self.response_hash
|
|
164
|
+
if self.committed_at is not None:
|
|
165
|
+
out["committed_at"] = self.committed_at
|
|
166
|
+
if self.compensation is not None:
|
|
167
|
+
out["compensation"] = self.compensation
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _now_millis() -> int:
|
|
172
|
+
return int(time.time() * 1000)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _row_to_record(row: sqlite3.Row) -> EffectRecord:
|
|
176
|
+
return EffectRecord(
|
|
177
|
+
effect_id=row["effect_id"],
|
|
178
|
+
session_id=row["session_id"],
|
|
179
|
+
idempotency_key=row["idempotency_key"],
|
|
180
|
+
action_type=row["action_type"],
|
|
181
|
+
target=row["target"],
|
|
182
|
+
effect_class=EffectClass(row["effect_class"]),
|
|
183
|
+
payload_hash=row["payload_hash"],
|
|
184
|
+
preconditions=tuple(json.loads(row["preconditions"])),
|
|
185
|
+
status=EffectStatus(row["status"]),
|
|
186
|
+
scope=row["scope"],
|
|
187
|
+
response_hash=row["response_hash"],
|
|
188
|
+
compensation=json.loads(row["compensation"]) if row["compensation"] else None,
|
|
189
|
+
created_at=int(row["created_at"]),
|
|
190
|
+
updated_at=int(row["updated_at"]),
|
|
191
|
+
committed_at=int(row["committed_at"]) if row["committed_at"] is not None else None,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class EffectStore:
|
|
196
|
+
"""SQLite-backed record store. One instance per agent session database.
|
|
197
|
+
|
|
198
|
+
``path`` may be ``":memory:"`` for tests. A file path is created along with
|
|
199
|
+
its parent directory, because the usual failure mode for a durable store is
|
|
200
|
+
that nobody made the directory.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, path: str | Path = ":memory:") -> None:
|
|
204
|
+
self.path = str(path)
|
|
205
|
+
if self.path != ":memory:":
|
|
206
|
+
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
self._lock = threading.RLock()
|
|
208
|
+
try:
|
|
209
|
+
self._conn = sqlite3.connect(
|
|
210
|
+
self.path,
|
|
211
|
+
check_same_thread=False,
|
|
212
|
+
isolation_level=None, # explicit transactions
|
|
213
|
+
)
|
|
214
|
+
self._conn.row_factory = sqlite3.Row
|
|
215
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
216
|
+
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
217
|
+
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
218
|
+
self._conn.executescript(_SCHEMA)
|
|
219
|
+
# Databases created before scope was persisted get the column added
|
|
220
|
+
# in place; the index is created after the migration because an old
|
|
221
|
+
# table has no scope column for it to reference.
|
|
222
|
+
columns = {row[1] for row in self._conn.execute("PRAGMA table_info(side_effects)")}
|
|
223
|
+
if "scope" not in columns:
|
|
224
|
+
self._conn.execute("ALTER TABLE side_effects ADD COLUMN scope TEXT")
|
|
225
|
+
self._conn.execute(
|
|
226
|
+
"CREATE INDEX IF NOT EXISTS idx_side_effects_scope ON side_effects (scope)"
|
|
227
|
+
)
|
|
228
|
+
except sqlite3.Error as exc: # pragma: no cover - environment dependent
|
|
229
|
+
raise StoreError(f"cannot open effect store at {self.path}: {exc}") from exc
|
|
230
|
+
|
|
231
|
+
def close(self) -> None:
|
|
232
|
+
with self._lock:
|
|
233
|
+
self._conn.close()
|
|
234
|
+
|
|
235
|
+
def __enter__(self) -> EffectStore:
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
def __exit__(self, *exc: object) -> None:
|
|
239
|
+
self.close()
|
|
240
|
+
|
|
241
|
+
# ── writes ──────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
def insert(
|
|
244
|
+
self,
|
|
245
|
+
*,
|
|
246
|
+
effect_id: str,
|
|
247
|
+
session_id: str,
|
|
248
|
+
idempotency_key: str,
|
|
249
|
+
action_type: str,
|
|
250
|
+
target: str,
|
|
251
|
+
effect_class: EffectClass,
|
|
252
|
+
payload_hash: str,
|
|
253
|
+
preconditions: tuple[str, ...],
|
|
254
|
+
status: EffectStatus = PENDING,
|
|
255
|
+
scope: str | None = None,
|
|
256
|
+
) -> EffectRecord | None:
|
|
257
|
+
"""Insert a new record, or return ``None`` if the key already exists.
|
|
258
|
+
|
|
259
|
+
The uniqueness check is the database's, so two concurrent attempts at
|
|
260
|
+
the same effect cannot both insert — the second gets ``None`` and must
|
|
261
|
+
adopt the existing record instead. Doing this with a SELECT first would
|
|
262
|
+
leave a window between the read and the write.
|
|
263
|
+
"""
|
|
264
|
+
now = _now_millis()
|
|
265
|
+
with self._lock:
|
|
266
|
+
try:
|
|
267
|
+
self._conn.execute(
|
|
268
|
+
"""
|
|
269
|
+
INSERT INTO side_effects (
|
|
270
|
+
effect_id, session_id, idempotency_key, action_type, target,
|
|
271
|
+
effect_class, payload_hash, preconditions, status, scope,
|
|
272
|
+
created_at, updated_at
|
|
273
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
274
|
+
""",
|
|
275
|
+
(
|
|
276
|
+
effect_id,
|
|
277
|
+
session_id,
|
|
278
|
+
idempotency_key,
|
|
279
|
+
action_type,
|
|
280
|
+
target,
|
|
281
|
+
effect_class.value,
|
|
282
|
+
payload_hash,
|
|
283
|
+
json.dumps(list(preconditions)),
|
|
284
|
+
status.value,
|
|
285
|
+
scope,
|
|
286
|
+
now,
|
|
287
|
+
now,
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
except sqlite3.IntegrityError:
|
|
291
|
+
return None
|
|
292
|
+
return self.get(effect_id)
|
|
293
|
+
|
|
294
|
+
def set_status(
|
|
295
|
+
self,
|
|
296
|
+
effect_id: str,
|
|
297
|
+
status: EffectStatus,
|
|
298
|
+
*,
|
|
299
|
+
response_hash: str | None = None,
|
|
300
|
+
) -> EffectRecord:
|
|
301
|
+
"""Advance a record's lifecycle state."""
|
|
302
|
+
if status not in _ALL_STATUSES:
|
|
303
|
+
raise StoreError(f"unknown status: {status!r}")
|
|
304
|
+
now = _now_millis()
|
|
305
|
+
committed_at = now if status == COMMITTED else None
|
|
306
|
+
with self._lock:
|
|
307
|
+
cur = self._conn.execute(
|
|
308
|
+
"""
|
|
309
|
+
UPDATE side_effects
|
|
310
|
+
SET status = ?,
|
|
311
|
+
updated_at = ?,
|
|
312
|
+
response_hash = COALESCE(?, response_hash),
|
|
313
|
+
committed_at = COALESCE(?, committed_at)
|
|
314
|
+
WHERE effect_id = ?
|
|
315
|
+
""",
|
|
316
|
+
(status.value, now, response_hash, committed_at, effect_id),
|
|
317
|
+
)
|
|
318
|
+
if cur.rowcount == 0:
|
|
319
|
+
raise StoreError(f"no such effect: {effect_id}")
|
|
320
|
+
record = self.get(effect_id)
|
|
321
|
+
if record is None: # pragma: no cover - rowcount check above covers this
|
|
322
|
+
raise StoreError(f"no such effect: {effect_id}")
|
|
323
|
+
return record
|
|
324
|
+
|
|
325
|
+
def transition_if_status(
|
|
326
|
+
self,
|
|
327
|
+
effect_id: str,
|
|
328
|
+
allowed: tuple[EffectStatus, ...],
|
|
329
|
+
status: EffectStatus,
|
|
330
|
+
) -> bool:
|
|
331
|
+
"""Atomically advance a record only while it is in one of *allowed*.
|
|
332
|
+
|
|
333
|
+
The read-then-write form this replaces let two concurrent retries of a
|
|
334
|
+
FAILED effect both observe ``failed`` and both go on to execute the
|
|
335
|
+
effect. Returns ``True`` only for the caller that actually performed the
|
|
336
|
+
transition; everyone else gets ``False`` and must adopt or refuse.
|
|
337
|
+
"""
|
|
338
|
+
if status not in _ALL_STATUSES:
|
|
339
|
+
raise StoreError(f"unknown status: {status!r}")
|
|
340
|
+
allowed_values = tuple(s.value for s in allowed)
|
|
341
|
+
if not allowed_values:
|
|
342
|
+
return False
|
|
343
|
+
placeholders = ", ".join("?" for _ in allowed_values)
|
|
344
|
+
with self._lock:
|
|
345
|
+
cur = self._conn.execute(
|
|
346
|
+
f"UPDATE side_effects SET status = ?, updated_at = ? "
|
|
347
|
+
f"WHERE effect_id = ? AND status IN ({placeholders})",
|
|
348
|
+
(status.value, _now_millis(), effect_id, *allowed_values),
|
|
349
|
+
)
|
|
350
|
+
return cur.rowcount == 1
|
|
351
|
+
|
|
352
|
+
def set_compensation(self, effect_id: str, data: dict[str, Any]) -> EffectRecord:
|
|
353
|
+
"""Attach the information needed to undo a reversible effect."""
|
|
354
|
+
with self._lock:
|
|
355
|
+
cur = self._conn.execute(
|
|
356
|
+
"UPDATE side_effects SET compensation = ?, updated_at = ? WHERE effect_id = ?",
|
|
357
|
+
(json.dumps(data), _now_millis(), effect_id),
|
|
358
|
+
)
|
|
359
|
+
if cur.rowcount == 0:
|
|
360
|
+
raise StoreError(f"no such effect: {effect_id}")
|
|
361
|
+
record = self.get(effect_id)
|
|
362
|
+
if record is None: # pragma: no cover
|
|
363
|
+
raise StoreError(f"no such effect: {effect_id}")
|
|
364
|
+
return record
|
|
365
|
+
|
|
366
|
+
# ── reads ───────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
def get(self, effect_id: str) -> EffectRecord | None:
|
|
369
|
+
with self._lock:
|
|
370
|
+
row = self._conn.execute(
|
|
371
|
+
"SELECT * FROM side_effects WHERE effect_id = ?", (effect_id,)
|
|
372
|
+
).fetchone()
|
|
373
|
+
return _row_to_record(row) if row else None
|
|
374
|
+
|
|
375
|
+
def find_by_key(self, session_id: str, idempotency_key: str) -> EffectRecord | None:
|
|
376
|
+
"""The lookup that makes an effect idempotent across restarts."""
|
|
377
|
+
with self._lock:
|
|
378
|
+
row = self._conn.execute(
|
|
379
|
+
"SELECT * FROM side_effects WHERE session_id = ? AND idempotency_key = ?",
|
|
380
|
+
(session_id, idempotency_key),
|
|
381
|
+
).fetchone()
|
|
382
|
+
return _row_to_record(row) if row else None
|
|
383
|
+
|
|
384
|
+
def list_records(
|
|
385
|
+
self,
|
|
386
|
+
*,
|
|
387
|
+
session_id: str | None = None,
|
|
388
|
+
status: EffectStatus | None = None,
|
|
389
|
+
scope: str | None = None,
|
|
390
|
+
limit: int = 1000,
|
|
391
|
+
) -> list[EffectRecord]:
|
|
392
|
+
clauses, params = [], []
|
|
393
|
+
if session_id is not None:
|
|
394
|
+
clauses.append("session_id = ?")
|
|
395
|
+
params.append(session_id)
|
|
396
|
+
if status is not None:
|
|
397
|
+
clauses.append("status = ?")
|
|
398
|
+
params.append(status.value)
|
|
399
|
+
if scope is not None:
|
|
400
|
+
# Scoped records live under a namespace session id, so filtering by
|
|
401
|
+
# the caller's session missed them entirely; scope is the column
|
|
402
|
+
# that survives a restart and identifies the run they belong to.
|
|
403
|
+
clauses.append("scope = ?")
|
|
404
|
+
params.append(scope)
|
|
405
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
406
|
+
params.append(limit)
|
|
407
|
+
with self._lock:
|
|
408
|
+
rows = self._conn.execute(
|
|
409
|
+
f"SELECT * FROM side_effects {where} ORDER BY created_at, effect_id LIMIT ?", # noqa: S608 - clauses are literals
|
|
410
|
+
params,
|
|
411
|
+
).fetchall()
|
|
412
|
+
return [_row_to_record(r) for r in rows]
|
|
413
|
+
|
|
414
|
+
def unresolved(
|
|
415
|
+
self, session_id: str | None = None, *, scope: str | None = None
|
|
416
|
+
) -> list[EffectRecord]:
|
|
417
|
+
"""Records whose outcome is *unknown* — the crash-recovery queue.
|
|
418
|
+
|
|
419
|
+
These are the effects an agent must resolve before doing anything else,
|
|
420
|
+
because for each of them the outside world may or may not have already
|
|
421
|
+
been changed. Pass *scope* to ask the cross-restart question for one
|
|
422
|
+
durable run id; the scoped record's session id is a derived namespace
|
|
423
|
+
and cannot be supplied by the caller.
|
|
424
|
+
"""
|
|
425
|
+
clauses = ["status IN (?, ?)"]
|
|
426
|
+
params: list[Any] = [EXECUTING.value, PENDING.value]
|
|
427
|
+
if session_id is not None:
|
|
428
|
+
clauses.append("session_id = ?")
|
|
429
|
+
params.append(session_id)
|
|
430
|
+
if scope is not None:
|
|
431
|
+
clauses.append("scope = ?")
|
|
432
|
+
params.append(scope)
|
|
433
|
+
with self._lock:
|
|
434
|
+
rows = self._conn.execute(
|
|
435
|
+
f"SELECT * FROM side_effects WHERE {' AND '.join(clauses)} " # noqa: S608
|
|
436
|
+
"ORDER BY created_at, effect_id",
|
|
437
|
+
params,
|
|
438
|
+
).fetchall()
|
|
439
|
+
return [_row_to_record(r) for r in rows]
|
|
440
|
+
|
|
441
|
+
def count_by_status(
|
|
442
|
+
self, session_id: str | None = None, *, scope: str | None = None
|
|
443
|
+
) -> dict[str, int]:
|
|
444
|
+
clauses, params = [], []
|
|
445
|
+
if session_id is not None:
|
|
446
|
+
clauses.append("session_id = ?")
|
|
447
|
+
params.append(session_id)
|
|
448
|
+
if scope is not None:
|
|
449
|
+
clauses.append("scope = ?")
|
|
450
|
+
params.append(scope)
|
|
451
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
452
|
+
with self._lock:
|
|
453
|
+
rows = self._conn.execute(
|
|
454
|
+
f"SELECT status, COUNT(*) AS n FROM side_effects {where} GROUP BY status", # noqa: S608
|
|
455
|
+
params,
|
|
456
|
+
).fetchall()
|
|
457
|
+
return {str(r["status"]): int(r["n"]) for r in rows}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Effect-class taxonomy for agent side effects.
|
|
2
|
+
|
|
3
|
+
Two properties of this table are deliberate departures from the earlier
|
|
4
|
+
implementation this derives from (see :mod:`honestcode.guards.guard` for the
|
|
5
|
+
third):
|
|
6
|
+
|
|
7
|
+
* ``classify`` defaults to *unknown → irreversible*, not → idempotent. The
|
|
8
|
+
2026 consensus across the effect-classification literature is that an
|
|
9
|
+
unrecognised action is the one you must not silently retry; defaulting an
|
|
10
|
+
unknown action to idempotent makes it retryable. Guessing "idempotent" for
|
|
11
|
+
something you do not recognise is the unsafe direction, and the failure it
|
|
12
|
+
produces (a duplicated external mutation) is exactly what this module exists
|
|
13
|
+
to prevent.
|
|
14
|
+
* The substring fallback is bounded to word-ish matches rather than a plain
|
|
15
|
+
``target.contains(...)``, so a path like ``/srv/pushkin/report.txt`` no
|
|
16
|
+
longer classifies as irreversible because it contains ``push``.
|
|
17
|
+
|
|
18
|
+
Four classes, in increasing severity: ``PURE`` < ``IDEMPOTENT`` <
|
|
19
|
+
``REVERSIBLE`` < ``IRREVERSIBLE``. The ordering is meaningful — it is the
|
|
20
|
+
default-deny direction — so members are comparable.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from enum import Enum
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"EffectClass",
|
|
30
|
+
"RecoveryStrategy",
|
|
31
|
+
"classify",
|
|
32
|
+
"READ_ONLY_ACTIONS",
|
|
33
|
+
"IDEMPOTENT_ACTIONS",
|
|
34
|
+
"REVERSIBLE_ACTIONS",
|
|
35
|
+
"IRREVERSIBLE_ACTIONS",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EffectClass(str, Enum):
|
|
40
|
+
"""How bad is it to run this action twice, or to not run it at all?
|
|
41
|
+
|
|
42
|
+
``str``-valued so instances survive JSON round-trips as plain strings,
|
|
43
|
+
which matters because these values are persisted and echoed back to
|
|
44
|
+
callers.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
PURE = "PURE"
|
|
48
|
+
"""No observable external change. Safe to repeat without limit."""
|
|
49
|
+
|
|
50
|
+
IDEMPOTENT = "IDEMPOTENT"
|
|
51
|
+
"""Repeating converges to the same state (``write_file``, ``upsert``)."""
|
|
52
|
+
|
|
53
|
+
REVERSIBLE = "REVERSIBLE"
|
|
54
|
+
"""Repeating changes state but the change can be undone, given the
|
|
55
|
+
original value (``edit_file``, ``patch``)."""
|
|
56
|
+
|
|
57
|
+
IRREVERSIBLE = "IRREVERSIBLE"
|
|
58
|
+
"""Repeating causes a second real-world event that cannot be undone
|
|
59
|
+
(``send_email``, ``charge``, ``git_push``, ``deploy``)."""
|
|
60
|
+
|
|
61
|
+
def __lt__(self, other: object) -> bool:
|
|
62
|
+
"""Compare by severity, so callers can gate on a threshold."""
|
|
63
|
+
if not isinstance(other, EffectClass):
|
|
64
|
+
return NotImplemented
|
|
65
|
+
return _SEVERITY[self] < _SEVERITY[other]
|
|
66
|
+
|
|
67
|
+
def __le__(self, other: object) -> bool:
|
|
68
|
+
if not isinstance(other, EffectClass):
|
|
69
|
+
return NotImplemented
|
|
70
|
+
return _SEVERITY[self] <= _SEVERITY[other]
|
|
71
|
+
|
|
72
|
+
def __gt__(self, other: object) -> bool:
|
|
73
|
+
if not isinstance(other, EffectClass):
|
|
74
|
+
return NotImplemented
|
|
75
|
+
return _SEVERITY[self] > _SEVERITY[other]
|
|
76
|
+
|
|
77
|
+
def __ge__(self, other: object) -> bool:
|
|
78
|
+
if not isinstance(other, EffectClass):
|
|
79
|
+
return NotImplemented
|
|
80
|
+
return _SEVERITY[self] >= _SEVERITY[other]
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def severity(self) -> int:
|
|
84
|
+
return _SEVERITY[self]
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def bare(self) -> str:
|
|
88
|
+
"""Value without the enum prefix — ``"IRREVERSIBLE"`` → ``"irreversible"``.
|
|
89
|
+
|
|
90
|
+
The persisted form and the wire form use this, because a caller reading
|
|
91
|
+
a JSON receipt should not have to know about a Python enum.
|
|
92
|
+
"""
|
|
93
|
+
return self.value.lower()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_SEVERITY = {
|
|
97
|
+
EffectClass.PURE: 0,
|
|
98
|
+
EffectClass.IDEMPOTENT: 1,
|
|
99
|
+
EffectClass.REVERSIBLE: 2,
|
|
100
|
+
EffectClass.IRREVERSIBLE: 3,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Re-exported for callers that want the raw tables.
|
|
104
|
+
_IDEMPOTENT = EffectClass.IDEMPOTENT
|
|
105
|
+
_REVERSIBLE = EffectClass.REVERSIBLE
|
|
106
|
+
_IRREVERSIBLE = EffectClass.IRREVERSIBLE
|
|
107
|
+
_PURE = EffectClass.PURE
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class RecoveryStrategy(str, Enum):
|
|
111
|
+
"""What to do with an effect whose outcome is unknown, per its class."""
|
|
112
|
+
|
|
113
|
+
REPLAY = "replay"
|
|
114
|
+
COMPENSATE = "compensate"
|
|
115
|
+
QUERY_AND_CONFIRM = "query_and_confirm"
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def bare(self) -> str:
|
|
119
|
+
return self.value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── classification tables ───────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
READ_ONLY_ACTIONS: frozenset[str] = frozenset(
|
|
125
|
+
{
|
|
126
|
+
"read_file",
|
|
127
|
+
"read",
|
|
128
|
+
"read_text",
|
|
129
|
+
"grep",
|
|
130
|
+
"glob",
|
|
131
|
+
"search",
|
|
132
|
+
"ls",
|
|
133
|
+
"list",
|
|
134
|
+
"list_dir",
|
|
135
|
+
"list_files",
|
|
136
|
+
"calculate",
|
|
137
|
+
"compute",
|
|
138
|
+
"fetch_url",
|
|
139
|
+
"get",
|
|
140
|
+
"stat",
|
|
141
|
+
"diff",
|
|
142
|
+
"view",
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
IDEMPOTENT_ACTIONS: frozenset[str] = frozenset(
|
|
147
|
+
{
|
|
148
|
+
"upsert",
|
|
149
|
+
"replace_text",
|
|
150
|
+
"write_file",
|
|
151
|
+
"write",
|
|
152
|
+
"set",
|
|
153
|
+
"put",
|
|
154
|
+
"create_or_update",
|
|
155
|
+
"ensure",
|
|
156
|
+
"mkdir",
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
REVERSIBLE_ACTIONS: frozenset[str] = frozenset(
|
|
161
|
+
{
|
|
162
|
+
"edit_file",
|
|
163
|
+
"edit",
|
|
164
|
+
"create_file",
|
|
165
|
+
"create",
|
|
166
|
+
"patch",
|
|
167
|
+
"rename",
|
|
168
|
+
"move",
|
|
169
|
+
"copy",
|
|
170
|
+
"insert",
|
|
171
|
+
"append",
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
IRREVERSIBLE_ACTIONS: frozenset[str] = frozenset(
|
|
176
|
+
{
|
|
177
|
+
"send_email",
|
|
178
|
+
"send_message",
|
|
179
|
+
"send",
|
|
180
|
+
"charge",
|
|
181
|
+
"pay",
|
|
182
|
+
"refund",
|
|
183
|
+
"transfer",
|
|
184
|
+
"git_push",
|
|
185
|
+
"push",
|
|
186
|
+
"deploy",
|
|
187
|
+
"release",
|
|
188
|
+
"publish",
|
|
189
|
+
"create_pr",
|
|
190
|
+
"merge",
|
|
191
|
+
"delete",
|
|
192
|
+
"drop",
|
|
193
|
+
"truncate",
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Bounded word matches for the fallback path. Substring matching on the raw
|
|
198
|
+
# target misfires on paths and identifiers that merely contain a keyword.
|
|
199
|
+
_IRREVERSIBLE_HINTS = re.compile(r"(?i)\b(email|mail|push|deploy|charge|refund|payment)\b")
|
|
200
|
+
_REVERSIBLE_HINTS = re.compile(r"(?i)\b(file|edit|patch|draft)\b")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def classify(action_type: str, target: str = "") -> EffectClass:
|
|
204
|
+
"""Classify an action by name, falling back to hints in *target*.
|
|
205
|
+
|
|
206
|
+
The fallback exists because agents call tools whose names this table has
|
|
207
|
+
never seen. When a name is unknown, the target is consulted for a hint, and
|
|
208
|
+
if that yields nothing either, the result is :attr:`EffectClass.IRREVERSIBLE`
|
|
209
|
+
— the safe default. An unrecognised action must be treated as the one that
|
|
210
|
+
cannot be safely retried, because that is the failure you cannot undo.
|
|
211
|
+
"""
|
|
212
|
+
name = (action_type or "").strip().lower().replace("-", "_")
|
|
213
|
+
if name in READ_ONLY_ACTIONS:
|
|
214
|
+
return _PURE
|
|
215
|
+
if name in IRREVERSIBLE_ACTIONS:
|
|
216
|
+
return _IRREVERSIBLE
|
|
217
|
+
if name in IDEMPOTENT_ACTIONS:
|
|
218
|
+
return _IDEMPOTENT
|
|
219
|
+
if name in REVERSIBLE_ACTIONS:
|
|
220
|
+
return _REVERSIBLE
|
|
221
|
+
|
|
222
|
+
# Unknown action: consult the target for a hint.
|
|
223
|
+
haystack = target or ""
|
|
224
|
+
if _IRREVERSIBLE_HINTS.search(haystack):
|
|
225
|
+
return _IRREVERSIBLE
|
|
226
|
+
if _REVERSIBLE_HINTS.search(haystack):
|
|
227
|
+
return _REVERSIBLE
|
|
228
|
+
return _IRREVERSIBLE
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Honest core: symbol indexing, routing and hallucination checks."""
|
|
2
|
+
|
|
3
|
+
from honestcode.honest.router import HonestRouter, RouteChoice, ToolIntent
|
|
4
|
+
from honestcode.honest.symbol_index import (
|
|
5
|
+
ProjectIndex,
|
|
6
|
+
SymbolIndex,
|
|
7
|
+
SymbolInfo,
|
|
8
|
+
get_project_index,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"HonestRouter",
|
|
13
|
+
"ProjectIndex",
|
|
14
|
+
"RouteChoice",
|
|
15
|
+
"SymbolIndex",
|
|
16
|
+
"SymbolInfo",
|
|
17
|
+
"ToolIntent",
|
|
18
|
+
"get_project_index",
|
|
19
|
+
]
|