handcode 0.3.0rc1__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.
- agentctl/__init__.py +0 -0
- agentctl/adapters/__init__.py +0 -0
- agentctl/adapters/litellm/__init__.py +9 -0
- agentctl/adapters/litellm/hook.py +49 -0
- agentctl/adapters/litellm/recorder.py +187 -0
- agentctl/adapters/openhands/__init__.py +169 -0
- agentctl/adapters/openhands/handoff.py +155 -0
- agentctl/adapters/openhands/seam_b.py +259 -0
- agentctl/adapters/openhands/seam_c.py +209 -0
- agentctl/cli.py +1450 -0
- agentctl/control/__init__.py +0 -0
- agentctl/control/cost/__init__.py +4 -0
- agentctl/control/cost/ledger.py +210 -0
- agentctl/control/dash.py +697 -0
- agentctl/control/keys.py +440 -0
- agentctl/control/matrix/__init__.py +0 -0
- agentctl/control/matrix/data/tools.yaml +149 -0
- agentctl/control/policy/__init__.py +10 -0
- agentctl/control/policy/compile.py +258 -0
- agentctl/control/policy/data/policy.compiled.json +38 -0
- agentctl/control/policy/data/policy.yaml +46 -0
- agentctl/control/probe.py +399 -0
- agentctl/control/providers.py +293 -0
- agentctl/control/proxy.py +536 -0
- agentctl/control/proxyenv.py +309 -0
- agentctl/control/replay/__init__.py +14 -0
- agentctl/control/replay/cassette.py +281 -0
- agentctl/control/replay/server.py +109 -0
- agentctl/demo/__init__.py +214 -0
- agentctl/demo/child.py +84 -0
- agentctl/demo/mock.py +79 -0
- agentctl/demo/tool.py +62 -0
- agentctl/gha.py +488 -0
- agentctl/kernel/__init__.py +0 -0
- agentctl/kernel/classify.py +170 -0
- agentctl/kernel/gate.py +391 -0
- agentctl/kernel/hook.py +229 -0
- agentctl/kernel/ledger/__init__.py +0 -0
- agentctl/kernel/ledger/models.py +160 -0
- agentctl/kernel/ledger/schema.sql +62 -0
- agentctl/kernel/ledger/store.py +596 -0
- agentctl/kernel/paths.py +203 -0
- agentctl/kernel/policy.py +160 -0
- agentctl/kernel/reconcile/__init__.py +31 -0
- agentctl/kernel/reconcile/base.py +106 -0
- agentctl/kernel/reconcile/external.py +137 -0
- agentctl/kernel/reconcile/filesystem.py +162 -0
- agentctl/kernel/reconcile/git.py +162 -0
- agentctl/runtime/__init__.py +20 -0
- agentctl/runtime/citations.py +179 -0
- agentctl/runtime/config.py +97 -0
- agentctl/runtime/doctor.py +335 -0
- agentctl/runtime/init.py +148 -0
- agentctl/runtime/lease.py +143 -0
- agentctl/runtime/orchestrate.py +187 -0
- agentctl/runtime/plugins.py +130 -0
- agentctl/runtime/report.py +361 -0
- agentctl/runtime/runner.py +787 -0
- agentctl/runtime/runs.py +191 -0
- agentctl/runtime/subagent.py +274 -0
- agentctl/runtime/tools.py +350 -0
- handcode-0.3.0rc1.dist-info/METADATA +659 -0
- handcode-0.3.0rc1.dist-info/RECORD +67 -0
- handcode-0.3.0rc1.dist-info/WHEEL +5 -0
- handcode-0.3.0rc1.dist-info/entry_points.txt +3 -0
- handcode-0.3.0rc1.dist-info/licenses/LICENSE +21 -0
- handcode-0.3.0rc1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Core types for the effect ledger.
|
|
2
|
+
|
|
3
|
+
Spec: `docs/0012` §2. No I/O here, no dependencies beyond the stdlib — this
|
|
4
|
+
module is imported by everything in the kernel.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EffectClass(str, Enum):
|
|
15
|
+
"""How a tool's side effect behaves under repetition.
|
|
16
|
+
|
|
17
|
+
Ordering matters: `DESTRUCTIVE` is the safe default direction, so an
|
|
18
|
+
unknown tool must never classify below `EXTERNAL`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
PURE_READ = "PURE_READ"
|
|
22
|
+
IDEMPOTENT_WRITE = "IDEMPOTENT_WRITE"
|
|
23
|
+
NON_IDEMPOTENT_WRITE = "NON_IDEMPOTENT_WRITE"
|
|
24
|
+
EXTERNAL = "EXTERNAL"
|
|
25
|
+
DESTRUCTIVE = "DESTRUCTIVE"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def severity(self) -> int:
|
|
29
|
+
"""Rank by danger. Used to resolve competing classification rules.
|
|
30
|
+
|
|
31
|
+
A command can match several rules (`ls && rm -rf x` matches both a
|
|
32
|
+
benign prefix and a destructive one). The classifier must take the
|
|
33
|
+
most dangerous match, never the first.
|
|
34
|
+
"""
|
|
35
|
+
return _SEVERITY[self]
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def replay_safe(self) -> bool:
|
|
39
|
+
"""Safe to run again when we cannot tell whether it already ran."""
|
|
40
|
+
return self in (EffectClass.PURE_READ, EffectClass.IDEMPOTENT_WRITE)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def speculation_safe(self) -> bool:
|
|
44
|
+
"""Safe to run *before* we know we want to, then discard.
|
|
45
|
+
|
|
46
|
+
Strictly stricter than `replay_safe` (`docs/0010` §6.4): an idempotent
|
|
47
|
+
write is safe to repeat but a discarded speculative write has still
|
|
48
|
+
mutated the world.
|
|
49
|
+
"""
|
|
50
|
+
return self is EffectClass.PURE_READ
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_SEVERITY: dict[EffectClass, int] = {
|
|
54
|
+
EffectClass.PURE_READ: 0,
|
|
55
|
+
EffectClass.IDEMPOTENT_WRITE: 1,
|
|
56
|
+
EffectClass.NON_IDEMPOTENT_WRITE: 2,
|
|
57
|
+
EffectClass.EXTERNAL: 3,
|
|
58
|
+
EffectClass.DESTRUCTIVE: 4,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class EffectState(str, Enum):
|
|
63
|
+
INTENT = "INTENT" # written before execution; the ambiguous state
|
|
64
|
+
COMMITTED = "COMMITTED" # tool returned; observation stored
|
|
65
|
+
#: The tool's result -- success OR reported failure -- is durably in the
|
|
66
|
+
#: conversation history the model reads. A later identical call is then
|
|
67
|
+
#: the model deciding to repeat, not the harness replaying (`docs/0045`).
|
|
68
|
+
OBSERVED = "OBSERVED"
|
|
69
|
+
FAILED = "FAILED" # provably did not land
|
|
70
|
+
BLOCKED = "BLOCKED" # fail-closed; awaiting a human
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
#: Legal transitions. Anything else is a bug and raises.
|
|
74
|
+
TRANSITIONS: dict[EffectState | None, set[EffectState]] = {
|
|
75
|
+
None: {EffectState.INTENT},
|
|
76
|
+
EffectState.INTENT: {
|
|
77
|
+
EffectState.COMMITTED, EffectState.FAILED,
|
|
78
|
+
EffectState.BLOCKED, EffectState.INTENT, # retry after FAILED bumps attempt
|
|
79
|
+
# Straight to OBSERVED in one write when the harness has already
|
|
80
|
+
# persisted the observation. Going through COMMITTED would open a crash
|
|
81
|
+
# window in which a reported failure sat as COMMITTED (`docs/0031`).
|
|
82
|
+
EffectState.OBSERVED,
|
|
83
|
+
},
|
|
84
|
+
EffectState.COMMITTED: {EffectState.OBSERVED},
|
|
85
|
+
EffectState.FAILED: {EffectState.INTENT},
|
|
86
|
+
EffectState.BLOCKED: {
|
|
87
|
+
EffectState.COMMITTED, EffectState.FAILED, EffectState.INTENT,
|
|
88
|
+
}, # human resolution only
|
|
89
|
+
EffectState.OBSERVED: set(),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class IllegalTransition(RuntimeError):
|
|
94
|
+
"""Raised inside the store, never past `EffectGate.guard`."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Verdict(str, Enum):
|
|
98
|
+
EXECUTE = "EXECUTE" # run the tool
|
|
99
|
+
SUBSTITUTE = "SUBSTITUTE" # return the stored observation (Seam C only)
|
|
100
|
+
BLOCK = "BLOCK" # refuse; fail closed
|
|
101
|
+
ESCALATE = "ESCALATE" # refuse; a human must decide
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class ToolCall:
|
|
106
|
+
"""One tool invocation, normalised away from any harness."""
|
|
107
|
+
|
|
108
|
+
tool_call_id: str
|
|
109
|
+
conversation_id: str
|
|
110
|
+
turn_id: str
|
|
111
|
+
tool_name: str
|
|
112
|
+
args: dict = field(default_factory=dict)
|
|
113
|
+
|
|
114
|
+
def intent_hash(self) -> str:
|
|
115
|
+
"""Stable fingerprint of *what this call would do*.
|
|
116
|
+
|
|
117
|
+
Guards against a `tool_call_id` being reused with different arguments,
|
|
118
|
+
which would otherwise let the ledger substitute the wrong observation.
|
|
119
|
+
"""
|
|
120
|
+
canonical = json.dumps(
|
|
121
|
+
{"t": self.tool_name, "a": self.args},
|
|
122
|
+
sort_keys=True, separators=(",", ":"), default=str,
|
|
123
|
+
)
|
|
124
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class EffectRecord:
|
|
129
|
+
tool_call_id: str
|
|
130
|
+
conversation_id: str
|
|
131
|
+
turn_id: str
|
|
132
|
+
tool_name: str
|
|
133
|
+
intent_hash: str
|
|
134
|
+
effect_class: EffectClass
|
|
135
|
+
state: EffectState
|
|
136
|
+
fence_token: int
|
|
137
|
+
attempt: int
|
|
138
|
+
started_at: float
|
|
139
|
+
committed_at: float | None = None
|
|
140
|
+
observation: bytes | None = None
|
|
141
|
+
probe_verdict: str | None = None
|
|
142
|
+
pre_state: str | None = None
|
|
143
|
+
error: str | None = None
|
|
144
|
+
action_event_id: str | None = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class GateDecision:
|
|
149
|
+
verdict: Verdict
|
|
150
|
+
effect_class: EffectClass | None = None
|
|
151
|
+
observation: bytes | None = None
|
|
152
|
+
reason: str | None = None
|
|
153
|
+
#: For SUBSTITUTE: the record whose result is being handed back. Under
|
|
154
|
+
#: intent-hash aliasing it is not the caller's id, and it is the record to
|
|
155
|
+
#: mark OBSERVED once the substituted result reaches the model.
|
|
156
|
+
record_id: str | None = None
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def allows_execution(self) -> bool:
|
|
160
|
+
return self.verdict is Verdict.EXECUTE
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
-- Effect ledger. docs/0012 §2.1
|
|
2
|
+
--
|
|
3
|
+
-- synchronous = FULL is deliberate and non-negotiable: NORMAL can lose the
|
|
4
|
+
-- last commit on power failure, and that is exactly the record whose absence
|
|
5
|
+
-- causes a double effect.
|
|
6
|
+
PRAGMA journal_mode = WAL;
|
|
7
|
+
PRAGMA synchronous = FULL;
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS effect_record (
|
|
10
|
+
tool_call_id TEXT PRIMARY KEY,
|
|
11
|
+
conversation_id TEXT NOT NULL,
|
|
12
|
+
turn_id TEXT NOT NULL,
|
|
13
|
+
action_event_id TEXT,
|
|
14
|
+
tool_name TEXT NOT NULL,
|
|
15
|
+
intent_hash TEXT NOT NULL,
|
|
16
|
+
effect_class TEXT NOT NULL,
|
|
17
|
+
state TEXT NOT NULL,
|
|
18
|
+
fence_token INTEGER NOT NULL,
|
|
19
|
+
attempt INTEGER NOT NULL DEFAULT 1,
|
|
20
|
+
started_at REAL NOT NULL,
|
|
21
|
+
committed_at REAL,
|
|
22
|
+
observation BLOB,
|
|
23
|
+
probe_verdict TEXT,
|
|
24
|
+
-- World fingerprint captured BEFORE execution, so a probe can later ask
|
|
25
|
+
-- "did anything change?" without the tool having cooperated. docs/0016b
|
|
26
|
+
pre_state TEXT,
|
|
27
|
+
error TEXT,
|
|
28
|
+
CHECK (state IN ('INTENT','COMMITTED','OBSERVED','FAILED','BLOCKED')),
|
|
29
|
+
CHECK (effect_class IN
|
|
30
|
+
('PURE_READ','IDEMPOTENT_WRITE','NON_IDEMPOTENT_WRITE','EXTERNAL','DESTRUCTIVE'))
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS ix_effect_conv ON effect_record(conversation_id, turn_id);
|
|
34
|
+
|
|
35
|
+
-- The recovery scan: everything needing attention after a crash.
|
|
36
|
+
CREATE INDEX IF NOT EXISTS ix_effect_open ON effect_record(state)
|
|
37
|
+
WHERE state IN ('INTENT','BLOCKED');
|
|
38
|
+
|
|
39
|
+
-- Fencing: one live writer per conversation.
|
|
40
|
+
CREATE TABLE IF NOT EXISTS lease (
|
|
41
|
+
conversation_id TEXT PRIMARY KEY,
|
|
42
|
+
holder TEXT NOT NULL,
|
|
43
|
+
fence_token INTEGER NOT NULL,
|
|
44
|
+
expires_at REAL NOT NULL
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
-- Decisions a human made on actions that asked first (docs/0042 I-06). With
|
|
48
|
+
-- no terminal to ask at, a dangerous action is queued as BLOCKED awaiting
|
|
49
|
+
-- approval; `agentctl approve` / `deny` records the answer here, keyed by
|
|
50
|
+
-- what the action IS (its intent hash), so the identical call the agent makes
|
|
51
|
+
-- on resume is recognised. An approval is used once.
|
|
52
|
+
CREATE TABLE IF NOT EXISTS approval (
|
|
53
|
+
conversation_id TEXT NOT NULL,
|
|
54
|
+
intent_hash TEXT NOT NULL,
|
|
55
|
+
tool_call_id TEXT,
|
|
56
|
+
summary TEXT,
|
|
57
|
+
decision TEXT NOT NULL CHECK (decision IN ('approve', 'deny')),
|
|
58
|
+
decided_at REAL NOT NULL,
|
|
59
|
+
told_at REAL,
|
|
60
|
+
used_at REAL,
|
|
61
|
+
PRIMARY KEY (conversation_id, intent_hash)
|
|
62
|
+
);
|