Skip to content

Commit c4f09f3

Browse files
committed
Add run export/undo, audit chain, config loader, plugins, and integration tests
1 parent f43d512 commit c4f09f3

17 files changed

Lines changed: 2131 additions & 16 deletions

AGENTS.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,47 @@
2323

2424
- Keep `SKILL.md` short and route details into `REFERENCE.md` or examples.
2525
- Treat skills as reviewed supply-chain assets, not casual prompt snippets.
26+
27+
28+
<claude-mem-context>
29+
# Memory Context
30+
31+
# [teaagent] recent context, 2026-05-14 4:12pm GMT+8
32+
33+
Legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖️decision 🚨security_alert 🔐security_note
34+
Format: ID TIME TYPE TITLE
35+
Fetch details: get_observations([IDs]) | Search: mem-search skill
36+
37+
Stats: 21 obs (8,973t read) | 185,455t work | 95% savings
38+
39+
### May 8, 2026
40+
22 12:31a 🟣 TeaAgent P0 Agent Harness — Initial Implementation
41+
23 12:35a 🟣 teaagent P1 primitives: trace, context, eval, RAG, skill review, AI-BOM
42+
24 12:54a 🟣 GraphQLite Graph RAG persistence layer added to teaagent
43+
25 1:00a 🟣 TeaAgent CLI with GraphQLite subcommands added
44+
S4 Generate commit message for staged changes adding interactive TUI to teaagent CLI (May 8 at 1:01 AM)
45+
S3 Generate commit message for staged CLI additions to teaagent project (May 8 at 1:01 AM)
46+
26 1:04a 🟣 Interactive TUI added to teaagent CLI
47+
S5 Generate commit message for staged changes adding LLM adapters, workspace tools, and chat agent to teaagent (May 8 at 1:05 AM)
48+
27 8:04a 🟣 Multi-Provider LLM Adapter Layer Added to teaagent
49+
28 " 🟣 ChatAgentConfig and ModelDecisionEngine for Autonomous Agent Runs
50+
29 " 🟣 Agent Prompt Assembly and JSON Decision Parsing in prompt.py
51+
30 " 🟣 CLI Extended with agent run, model, doctor model, and workspace subcommands
52+
31 " 🟣 ApprovalPolicy Gains allow_all_destructive Bypass Flag
53+
S6 Generate commit message for staged changes adding permission modes and hash-anchored workspace edits to teaagent (May 8 at 8:05 AM)
54+
32 8:08a 🟣 Run Store, CLI, and TUI Support Added to teaagent
55+
33 " 🟣 RunStore: Persistent JSONL-based Agent Run History
56+
34 8:32a 🟣 PermissionMode Enum and Granular Tool Access Control
57+
35 " 🟣 Hash-Anchored Line Editing Tools
58+
36 " 🟣 Shell Tool Split: workspace_run_shell_inspect vs workspace_run_shell_mutate
59+
37 " 🟣 --permission-mode CLI Flag and TUI permission Command
60+
S7 Generate commit message for staged changes — TeaAgent intent clarification layer (May 8 at 8:33 AM)
61+
38 8:39a 🟣 Deterministic task clarification system added to TeaAgent
62+
39 8:46a 🟣 Memory Catalog added to teaagent
63+
S8 Add workspace memory catalog to teaagent — new MemoryCatalog feature with CLI, TUI, and agent prompt injection (May 8 at 8:46 AM)
64+
40 8:57a 🟣 Deterministic task-based model routing added to teaagent
65+
41 2:07p 🟣 MCP HTTP Transport Layer Added to teaagent
66+
42 2:08p 🟣 Streamable HTTP Transport for MCP Server Implemented
67+
68+
Access 185k tokens of past work via get_observations([IDs]) or mem-search skill.
69+
</claude-mem-context>

docs/acceptance.md

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,128 @@ verifiable so that tampering is detectable.*
174174

175175
---
176176

177+
#### Audit Hash Chain Integrity (new)
178+
179+
**File:** `test_audit_chain.py` (integration)
180+
181+
**Story (P1):** *As a security lead, I want each persisted audit event to carry
182+
a SHA-256 hash of the previous event so that any tampering is
183+
cryptographically detectable.*
184+
185+
| Assertion | Details |
186+
|---|---|
187+
| Clean log verifies OK | `verify_audit_chain()` returns `valid=True` |
188+
| First event has `prev_hash="genesis"` | Chain anchor constant |
189+
| Second event's `prev_hash` equals first's `hash` | Sequential linkage |
190+
| Content tampering detected | Mutating `event_type` breaks the hash |
191+
| Event insertion detected | Foreign event with wrong `prev_hash` rejected |
192+
| Event deletion detected | Gap in chain detected on line 3 |
193+
| Multi-run log verifies OK | Two runs in one JSONL all pass |
194+
195+
---
196+
197+
#### Plugin Loader (new)
198+
199+
**File:** `test_plugins.py` (integration)
200+
201+
**Story (P1):** *As a third-party author, I want to register tools via
202+
`teaagent.tools` entry-points so that `pip install` adds tools with no manual
203+
wiring.*
204+
205+
| Assertion | Details |
206+
|---|---|
207+
| No plugins → empty result | `loaded == []`, `failed == []` |
208+
| Single plugin registers tool | Tool appears in `ToolRegistry` |
209+
| Multiple plugins all loaded | 3 entry-points → 3 tools |
210+
| Failing plugin isolated | Bad plugin in `failed`, others unaffected |
211+
| `ep.load()` error isolated | `ImportError` quarantined |
212+
| Custom group respected | `group=` parameter forwarded to `entry_points()` |
213+
| `PLUGIN_GROUP == 'teaagent.tools'` | Canonical group constant |
214+
215+
---
216+
217+
#### A2A Circuit Breaker (new)
218+
219+
**File:** `test_a2a_circuit_breaker.py` (integration)
220+
221+
**Story (P1):** *As a platform engineer, I want unhealthy remote agents to be
222+
skipped automatically so that one bad endpoint does not block the entire
223+
federation refresh.*
224+
225+
| Assertion | Details |
226+
|---|---|
227+
| Circuit opens after `failure_threshold` | `circuit_state()` returns `'open'` |
228+
| Open circuit skips endpoint | Second refresh URL not in call log |
229+
| Success resets failure count | `circuit_state()` returns `'closed'` |
230+
| Circuit resets after `reset_timeout_seconds` | Half-open → retry → closed |
231+
| No `circuit_breaker` → retries every time | Backward-compatible default |
232+
| Unknown endpoint is `'closed'` | `circuit_state('http://unknown')` = `'closed'` |
233+
| Healthy endpoints still return cards | Even when one is open |
234+
235+
---
236+
237+
#### Config Layering + Workspace Profile (new)
238+
239+
**File:** `test_config_loader.py` (integration)
240+
241+
**Story (P2 — AC-NEW-3):** *As a developer, I want my workspace
242+
`.teaagent/config.json` to set default permission-mode and iteration limits so
243+
that I don't have to repeat CLI flags on every run.*
244+
245+
| Assertion | Details |
246+
|---|---|
247+
| `ResolvedConfig.get()` returns value | By key name |
248+
| `ResolvedConfig.source()` returns layer | `ConfigLayer.WORKSPACE` etc. |
249+
| `show()` lists key=value [layer] lines | Human-readable |
250+
| Workspace config loaded | `.teaagent/config.json` values in `ResolvedConfig` |
251+
| User config loaded | `~/.teaagent/config.json` values in `ResolvedConfig` |
252+
| Workspace overrides user | Same key → workspace wins |
253+
| Env overrides workspace | `TEAAGENT_PERMISSION_MODE` beats file |
254+
| `ChatAgentConfig.from_root()` applies profile | `permission_mode`, `max_iterations` set from config |
255+
| Explicit kwargs override profile | Caller wins over workspace config |
256+
257+
---
258+
259+
#### Run Undo Journal (new)
260+
261+
**File:** `test_run_undo.py` (integration)
262+
263+
**Story (P2 — AC-NEW-6):** *As a developer, I want `UndoJournal` to track
264+
pre-write file state so that I can revert all workspace writes from a run.*
265+
266+
| Assertion | Details |
267+
|---|---|
268+
| Newly created file deleted on `restore()` | Path in `UndoResult.deleted` |
269+
| Overwritten file restored | Original content re-written; path in `restored` |
270+
| Multiple writes all undone | Two files → two deletions |
271+
| Idempotent when no writes | Empty `restore()` with no errors |
272+
| `UndoResult.ok` false on errors | Error list non-empty → `ok=False` |
273+
| Read-only tools not captured | No journal entry for `workspace_read_file` |
274+
| Path traversal attempts ignored | `../` outside root silently dropped |
275+
276+
---
277+
278+
#### Run Export / Import (new)
279+
280+
**File:** `test_run_export.py` (integration)
281+
282+
**Story (P2 — AC-NEW-19):** *As a user, I want to export a run to a portable
283+
archive and import it on another machine so that runs are reproducible and
284+
shareable.*
285+
286+
| Assertion | Details |
287+
|---|---|
288+
| `export_run()` creates `.tar.gz` | Archive exists at `output_path` |
289+
| `import_run()` restores run | Events appear in destination `RunStore` |
290+
| Round-trip preserves all event fields | `src_events == dst_events` |
291+
| Import does not clobber other runs | Pre-existing run survives import |
292+
| `ExportManifest` fields correct | `run_id`, `event_count`, `archive_path` |
293+
| Import missing archive → `FileNotFoundError` | Clear error |
294+
| Export missing run → `FileNotFoundError` | Clear error |
295+
| Hash chain valid after import | `verify_audit_chain()` passes on imported log |
296+
297+
---
298+
177299
## Integration Tests (tests/integration/)
178300

179301
| File | Coverage |
@@ -193,6 +315,12 @@ verifiable so that tampering is detectable.*
193315
| `test_streaming_tool_calls.py` | `on_chunk` callbacks, audit events, token accumulation (IT-13) |
194316
| `test_schema_migration_live.py` | Migration ordering, idempotency, data survival, version tracking (IT-14) |
195317
| `test_dpop_replay_concurrency.py` | Concurrent JTI consume: exactly one success; expiry reset (IT-15) |
318+
| `test_audit_chain.py` | Hash-chain validity, tampering/insertion/deletion detection (new) |
319+
| `test_plugins.py` | Plugin discovery, isolation, custom group (new) |
320+
| `test_a2a_circuit_breaker.py` | Circuit open/close, skip, reset, backward compat (new) |
321+
| `test_config_loader.py` | Layer precedence, env override, workspace profile applied (new) |
322+
| `test_run_undo.py` | Pre-write capture, file deletion/restore, path traversal guard (new) |
323+
| `test_run_export.py` | Export/import round-trip, hash-chain survival, error cases (new) |
196324

197325
---
198326

@@ -204,16 +332,13 @@ acceptance tests. Each maps to a Review gap analysis recommendation.
204332
| ID | Story | Priority |
205333
|---|---|---|
206334
| AC-NEW-1 | `teaagent init` first-time wizard | P2 |
207-
| AC-NEW-3 | Workspace profile (`.teaagent/profile.toml`) | P2 |
208335
| AC-NEW-4 | Interactive diff preview + inline y/n approval | P0 |
209-
| AC-NEW-6 | `teaagent run undo <run_id>` workspace rollback | P2 |
210336
| AC-NEW-8 | Desktop/webhook notification on ultrawork completion | P2 |
211337
| AC-NEW-11 | A2A delegation carries `traceparent` header | P1 |
212338
| AC-NEW-15 | Configurable PII redaction categories | P2 |
213339
| AC-NEW-16 | HTTPS_PROXY / CA bundle / mTLS "just works" | P2 |
214340
| AC-NEW-17 | `teaagent upgrade` schema migration dry-run preview | P2 |
215341
| AC-NEW-18 | Graceful degradation when disk fills mid-run | P2 |
216-
| AC-NEW-19 | `teaagent run export / import` | P2 |
217342
| AC-NEW-20 | Local model (Ollama/vLLM) with full governance | P2 |
218343
| AC-NEW-21 | `teaagent eval run <suite>` with HTML report | P2 |
219344
| AC-NEW-22 | `teaagent benchmark` latency/cost regression tracking | P2 |

teaagent/__init__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@
77
except PackageNotFoundError:
88
__version__ = '0+local'
99

10+
from teaagent.agentcard import CircuitBreakerConfig
1011
from teaagent.aibom import AIBOMManifest, build_aibom
1112
from teaagent.audit import AuditLogger
13+
from teaagent.audit_chain import (
14+
ChainVerificationResult,
15+
compute_event_hash,
16+
verify_audit_chain,
17+
)
1218
from teaagent.budget import RunBudget
1319
from teaagent.chat_agent import (
1420
ChatAgentConfig,
@@ -24,6 +30,13 @@
2430
UnsafeCodeError,
2531
execute_code_mode,
2632
)
33+
from teaagent.config_loader import (
34+
CONFIG_KEYS,
35+
ConfigLayer,
36+
ConfigResolver,
37+
ResolvedConfig,
38+
load_workspace_config,
39+
)
2740
from teaagent.context import ContextCompactor
2841
from teaagent.errors import RunCancelledError
2942
from teaagent.eval import EvalCase, EvalReport, run_eval
@@ -96,6 +109,7 @@
96109
generate_code_verifier,
97110
verify_jwt,
98111
)
112+
from teaagent.plugins import PluginLoadResult, load_plugins
99113
from teaagent.policy import ApprovalPolicy, PermissionMode, parse_permission_mode
100114
from teaagent.portability import (
101115
PortabilityResult,
@@ -106,7 +120,9 @@
106120
from teaagent.prompt import PromptBundle, assemble_agent_prompt, parse_model_decision
107121
from teaagent.rag import Document, InMemoryRetriever, agentic_retrieve
108122
from teaagent.readiness import ReadinessReport, assess_managed_agent_readiness
123+
from teaagent.run_export import ExportManifest, export_run, import_run
109124
from teaagent.run_store import RunStore, RunSummary
125+
from teaagent.run_undo import UndoJournal, UndoResult
110126
from teaagent.runner import (
111127
AgentRunner,
112128
ApprovalRequest,
@@ -150,6 +166,15 @@
150166
'ApprovalPolicy',
151167
'ApprovalRequest',
152168
'AuditLogger',
169+
'CircuitBreakerConfig',
170+
'CONFIG_KEYS',
171+
'ConfigLayer',
172+
'ConfigResolver',
173+
'ResolvedConfig',
174+
'load_workspace_config',
175+
'ChainVerificationResult',
176+
'compute_event_hash',
177+
'verify_audit_chain',
153178
'ChatAgentConfig',
154179
'ChildProcessCodeModeBackend',
155180
'ClarificationResult',
@@ -211,6 +236,13 @@
211236
'OAuthKeyRing',
212237
'OAuthStore',
213238
'PermissionMode',
239+
'ExportManifest',
240+
'PluginLoadResult',
241+
'UndoJournal',
242+
'UndoResult',
243+
'export_run',
244+
'import_run',
245+
'load_plugins',
214246
'PortabilityResult',
215247
'PreflightReport',
216248
'PromptBundle',

teaagent/agentcard.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,40 @@
1313
from typing import Any, Callable, Optional
1414

1515

16+
@dataclass(frozen=True)
17+
class CircuitBreakerConfig:
18+
"""Per-endpoint circuit-breaker parameters for :class:`FederatedAgentRegistry`.
19+
20+
After *failure_threshold* consecutive failures the circuit opens and the
21+
endpoint is skipped on subsequent refreshes. Once *reset_timeout_seconds*
22+
have elapsed the circuit moves to half-open and the next refresh retries
23+
the endpoint; a success closes the circuit, another failure re-opens it.
24+
"""
25+
26+
failure_threshold: int = 3
27+
reset_timeout_seconds: float = 60.0
28+
29+
30+
@dataclass
31+
class _EndpointCircuit:
32+
failures: int = 0
33+
opened_at: Optional[float] = None # monotonic timestamp when circuit opened
34+
35+
def is_open(self, reset_timeout: float) -> bool:
36+
if self.opened_at is None:
37+
return False
38+
return (_time.monotonic() - self.opened_at) < reset_timeout
39+
40+
def record_failure(self, threshold: int) -> None:
41+
self.failures += 1
42+
if self.failures >= threshold:
43+
self.opened_at = _time.monotonic()
44+
45+
def record_success(self) -> None:
46+
self.failures = 0
47+
self.opened_at = None
48+
49+
1650
@dataclass(frozen=True)
1751
class AgentCard:
1852
name: str
@@ -376,12 +410,15 @@ def __init__(
376410
*,
377411
ttl_seconds: int = 300,
378412
timeout: int = 10,
413+
circuit_breaker: Optional[CircuitBreakerConfig] = None,
379414
) -> None:
380415
self._endpoints = list(endpoints)
381416
self._ttl = ttl_seconds
382417
self._timeout = timeout
383418
self._cache: list[AgentCard] = []
384419
self._fetched_at: float = 0.0
420+
self._cb_config = circuit_breaker
421+
self._circuits: dict[str, _EndpointCircuit] = {}
385422

386423
def _is_stale(self) -> bool:
387424
return _time.monotonic() - self._fetched_at > self._ttl
@@ -390,17 +427,38 @@ def refresh(self) -> list[str]:
390427
errors: list[str] = []
391428
cards: list[AgentCard] = []
392429
for base_url in self._endpoints:
430+
circuit = self._circuits.setdefault(base_url, _EndpointCircuit())
431+
if self._cb_config is not None and circuit.is_open(
432+
self._cb_config.reset_timeout_seconds
433+
):
434+
continue # circuit open — skip this endpoint
393435
url = base_url.rstrip('/') + '/.well-known/agent.json'
394436
try:
395437
with urllib.request.urlopen(url, timeout=self._timeout) as resp:
396438
data = json.loads(resp.read().decode('utf-8'))
397439
cards.append(AgentCard.from_dict(data))
440+
circuit.record_success()
398441
except Exception as exc:
399442
errors.append(f'{base_url}: {exc}')
443+
if self._cb_config is not None:
444+
circuit.record_failure(self._cb_config.failure_threshold)
400445
self._cache = cards
401446
self._fetched_at = _time.monotonic()
402447
return errors
403448

449+
def circuit_state(self, base_url: str) -> str:
450+
"""Return ``'open'`` or ``'closed'`` for the given endpoint."""
451+
circuit = self._circuits.get(base_url)
452+
if circuit is None:
453+
return 'closed'
454+
if self._cb_config is None:
455+
return 'closed'
456+
return (
457+
'open'
458+
if circuit.is_open(self._cb_config.reset_timeout_seconds)
459+
else 'closed'
460+
)
461+
404462
def _ensure_fresh(self) -> None:
405463
if self._is_stale():
406464
self.refresh()

0 commit comments

Comments
 (0)