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.
@@ -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
+ ]