@invokehq/cli 0.2.6 → 0.2.8

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.
package/README.md CHANGED
@@ -30,7 +30,7 @@ invoke init support-agent --template crm-guardrail
30
30
  cd support-agent
31
31
 
32
32
  # 4. Run its local MCP server
33
- invoke dev
33
+ invoke dev install
34
34
 
35
35
  # 5. Register its tools with Invoke
36
36
  invoke deploy
package/agentify.py CHANGED
@@ -1165,6 +1165,18 @@ def sample_sdk_source() -> str:
1165
1165
  def init_command(args: argparse.Namespace) -> int:
1166
1166
  root = Path(args.name)
1167
1167
  project_name = root.name
1168
+
1169
+ if args.template == "claude-agent":
1170
+ from invoke.deploy import copy_template
1171
+
1172
+ path = copy_template(root, force=args.force)
1173
+ print(f"Created Claude Agent SDK project at {path}")
1174
+ print("Next:")
1175
+ print(f" cd {path}")
1176
+ print(" npm install")
1177
+ print(" invoke deploy --dry-run")
1178
+ return 0
1179
+
1168
1180
  if root.exists() and any(root.iterdir()) and not args.force:
1169
1181
  raise ValueError(f"{root} already exists and is not empty. Pass --force to write into it.")
1170
1182
  root.mkdir(parents=True, exist_ok=True)
@@ -1214,6 +1226,31 @@ def read_project(root: Path) -> dict[str, Any]:
1214
1226
  return config
1215
1227
 
1216
1228
 
1229
+ def read_project_config(root: Path) -> dict[str, Any]:
1230
+ config_path = root / "invoke.json"
1231
+ if not config_path.exists():
1232
+ return {}
1233
+ config = load_json_file(config_path, {})
1234
+ if not isinstance(config, dict):
1235
+ raise ValueError("invoke.json must contain an object")
1236
+ return config
1237
+
1238
+
1239
+ def is_agent_project(root: Path, config: dict[str, Any]) -> bool:
1240
+ runtime = str(config.get("runtime") or "").lower()
1241
+ agent_type = str(config.get("agent_type") or config.get("type") or "").lower()
1242
+ has_entrypoint = bool(config.get("entrypoint")) or any(
1243
+ (root / candidate).exists() for candidate in ("src/index.ts", "src/index.js", "index.ts", "index.js")
1244
+ )
1245
+ tools = config.get("tools")
1246
+ empty_tools = tools is None or tools == []
1247
+ return has_entrypoint and (
1248
+ runtime in {"node", "python", "claude-agent-sdk"}
1249
+ or agent_type in {"agent", "claude-agent", "claude-agent-sdk"}
1250
+ or empty_tools
1251
+ )
1252
+
1253
+
1217
1254
  def project_mcp_url(root: Path, config: dict[str, Any], explicit_mcp_url: str | None = None) -> str | None:
1218
1255
  if explicit_mcp_url:
1219
1256
  return explicit_mcp_url
@@ -1371,6 +1408,35 @@ def save_deployment(record: dict[str, Any]) -> None:
1371
1408
 
1372
1409
  def deploy_command(args: argparse.Namespace) -> int:
1373
1410
  root = Path(args.path)
1411
+ raw_config = read_project_config(root)
1412
+ if is_agent_project(root, raw_config):
1413
+ from dataclasses import asdict
1414
+
1415
+ from invoke.deploy import deploy_claude_agent
1416
+
1417
+ result = deploy_claude_agent(
1418
+ root,
1419
+ app_name=args.slug or raw_config.get("slug") or raw_config.get("name"),
1420
+ dry_run=args.dry_run,
1421
+ )
1422
+ record = {
1423
+ "project": str(root.resolve()),
1424
+ "name": result.plan.app_name,
1425
+ "provider_id": result.deployment_id,
1426
+ "slug": result.plan.app_name,
1427
+ "gateway_url": result.endpoint_url,
1428
+ "dashboard_url": result.dashboard_url,
1429
+ "base_url": "modal",
1430
+ "tools": ["agent.run"],
1431
+ "deployed_at": dt.datetime.now(dt.timezone.utc).isoformat(),
1432
+ "mode": "modal_agent",
1433
+ "trace_path": result.trace_path,
1434
+ }
1435
+ if not args.dry_run:
1436
+ save_deployment(record)
1437
+ print(json.dumps({"success": result.success, "mode": "modal_agent", **asdict(result)}, indent=2))
1438
+ return 0
1439
+
1374
1440
  config = read_project(root)
1375
1441
  tools = config["tools"]
1376
1442
  mcp_url = project_mcp_url(root, config, args.mcp_url)
@@ -1773,7 +1839,7 @@ def build_parser() -> argparse.ArgumentParser:
1773
1839
 
1774
1840
  init = subparsers.add_parser("init", help="Scaffold an Invoke project.")
1775
1841
  init.add_argument("name", help="Project directory/name.")
1776
- init.add_argument("--template", choices=["default", "linear", "crm-guardrail"], default="default")
1842
+ init.add_argument("--template", choices=["default", "linear", "crm-guardrail", "claude-agent"], default="default")
1777
1843
  init.add_argument("--force", action="store_true", help="Write into an existing non-empty directory.")
1778
1844
  init.set_defaults(func=init_command)
1779
1845
 
@@ -0,0 +1,5 @@
1
+ """Invoke agent deployment and supervision primitives."""
2
+
3
+ from .deploy import DeployPlan, DeployResult, deploy_claude_agent
4
+
5
+ __all__ = ["DeployPlan", "DeployResult", "deploy_claude_agent"]
@@ -0,0 +1,243 @@
1
+ """Modal-backed deployment planning for Claude Agent SDK projects.
2
+
3
+ This module is intentionally small and import-safe. The public CLI can call
4
+ `deploy_claude_agent(path)` without importing Modal unless the caller actually
5
+ wants to deploy. That lets the local CLI stay lightweight while giving us a
6
+ real place to evolve hosted agent deployment.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ import json
13
+ import os
14
+ import shutil
15
+ from dataclasses import asdict, dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from .onyx.analyzer import OnyxSuggestion, analyze_traces
20
+ from .supervisor.monitor import TraceStore, build_boot_trace
21
+
22
+
23
+ DEFAULT_ENTRYPOINTS = ("src/index.ts", "src/index.js", "index.ts", "index.js")
24
+ DEFAULT_MODAL_IMAGE = "invoke/claude-agent-sdk:latest"
25
+
26
+
27
+ @dataclass
28
+ class DeployPlan:
29
+ """A concrete deployment plan for one agent project."""
30
+
31
+ project_root: str
32
+ app_name: str
33
+ entrypoint: str
34
+ modal_volume: str
35
+ modal_image: str = DEFAULT_MODAL_IMAGE
36
+ env: dict[str, str] = field(default_factory=dict)
37
+ endpoint_name: str = "invoke"
38
+ tracing_enabled: bool = True
39
+ persistence: dict[str, str] = field(default_factory=dict)
40
+ robustness: dict[str, bool] = field(default_factory=dict)
41
+
42
+
43
+ @dataclass
44
+ class DeployResult:
45
+ """Result returned by a deploy attempt or dry run."""
46
+
47
+ success: bool
48
+ plan: DeployPlan
49
+ deployment_id: str
50
+ status: str
51
+ message: str
52
+ modal_app_name: str | None = None
53
+ endpoint_url: str | None = None
54
+ dashboard_url: str | None = None
55
+ trace_path: str | None = None
56
+ modal_source_path: str | None = None
57
+ onyx_suggestions: list[OnyxSuggestion] = field(default_factory=list)
58
+
59
+
60
+ def slugify(value: str) -> str:
61
+ cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in value)
62
+ return "-".join(part for part in cleaned.split("-") if part) or "invoke-agent"
63
+
64
+
65
+ def now_iso() -> str:
66
+ return dt.datetime.now(dt.timezone.utc).isoformat()
67
+
68
+
69
+ def load_project_config(project_root: Path) -> dict[str, Any]:
70
+ for name in ("invoke.json", "agent.json", "package.json"):
71
+ path = project_root / name
72
+ if path.exists():
73
+ try:
74
+ data = json.loads(path.read_text(encoding="utf-8"))
75
+ except json.JSONDecodeError as exc:
76
+ raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
77
+ if isinstance(data, dict):
78
+ return data
79
+ return {}
80
+
81
+
82
+ def infer_entrypoint(project_root: Path, config: dict[str, Any]) -> str:
83
+ configured = config.get("entrypoint") or config.get("main")
84
+ candidates = (str(configured),) if configured else DEFAULT_ENTRYPOINTS
85
+ for candidate in candidates:
86
+ if candidate and (project_root / candidate).exists():
87
+ return candidate
88
+ raise FileNotFoundError(
89
+ "Could not find an agent entrypoint. Expected one of: "
90
+ + ", ".join(DEFAULT_ENTRYPOINTS)
91
+ + ". You can also set `entrypoint` in invoke.json."
92
+ )
93
+
94
+
95
+ def build_deploy_plan(project_path: str | Path, *, app_name: str | None = None) -> DeployPlan:
96
+ project_root = Path(project_path).expanduser().resolve()
97
+ if not project_root.exists() or not project_root.is_dir():
98
+ raise FileNotFoundError(f"Agent project does not exist: {project_root}")
99
+
100
+ config = load_project_config(project_root)
101
+ inferred_name = app_name or config.get("name") or project_root.name
102
+ slug = slugify(str(inferred_name))
103
+ entrypoint = infer_entrypoint(project_root, config)
104
+
105
+ env_keys = [
106
+ "ANTHROPIC_API_KEY",
107
+ "OPENAI_API_KEY",
108
+ "INVOKE_API_KEY",
109
+ "INVOKE_BASE_URL",
110
+ ]
111
+ env = {key: os.environ[key] for key in env_keys if os.environ.get(key)}
112
+
113
+ return DeployPlan(
114
+ project_root=str(project_root),
115
+ app_name=slug,
116
+ entrypoint=entrypoint,
117
+ modal_volume=f"{slug}-state",
118
+ env=env,
119
+ persistence={
120
+ "kind": "modal_volume",
121
+ "mount_path": "/state",
122
+ "trace_path": "/state/traces.jsonl",
123
+ "checkpoint_path": "/state/checkpoints.jsonl",
124
+ },
125
+ robustness={
126
+ "schema_checks": True,
127
+ "policy_checks": True,
128
+ "state_reconciliation": True,
129
+ "freeze_thaw_hitl": True,
130
+ "structured_tracing": True,
131
+ },
132
+ )
133
+
134
+
135
+ def write_local_deploy_record(result: DeployResult) -> Path:
136
+ root = Path(result.plan.project_root)
137
+ state_dir = root / ".invoke"
138
+ state_dir.mkdir(parents=True, exist_ok=True)
139
+ path = state_dir / "deployment.json"
140
+ path.write_text(json.dumps(asdict(result), indent=2, sort_keys=True) + "\n", encoding="utf-8")
141
+ return path
142
+
143
+
144
+ def copy_template(destination: str | Path, *, force: bool = False) -> Path:
145
+ """Scaffold a Claude Agent SDK starter project."""
146
+
147
+ destination = Path(destination).expanduser().resolve()
148
+ template_root = Path(__file__).with_name("templates") / "claude-agent-sdk"
149
+ if destination.exists() and any(destination.iterdir()) and not force:
150
+ raise FileExistsError(f"{destination} is not empty. Pass force=True to overwrite template files.")
151
+ destination.mkdir(parents=True, exist_ok=True)
152
+ for source in template_root.rglob("*"):
153
+ target = destination / source.relative_to(template_root)
154
+ if source.is_dir():
155
+ target.mkdir(parents=True, exist_ok=True)
156
+ continue
157
+ target.parent.mkdir(parents=True, exist_ok=True)
158
+ if target.exists() and not force:
159
+ continue
160
+ shutil.copyfile(source, target)
161
+ return destination
162
+
163
+
164
+ def deploy_claude_agent(
165
+ project_path: str | Path,
166
+ *,
167
+ app_name: str | None = None,
168
+ dry_run: bool = False,
169
+ run_onyx: bool = True,
170
+ ) -> DeployResult:
171
+ """Build and optionally deploy a Claude Agent SDK project to Modal."""
172
+
173
+ plan = build_deploy_plan(project_path, app_name=app_name)
174
+ deployment_id = "dep_" + dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d%H%M%S")
175
+
176
+ trace_store = TraceStore(Path(plan.project_root) / ".invoke" / "traces.jsonl")
177
+ boot_trace = build_boot_trace(plan.app_name, plan.entrypoint, plan.robustness)
178
+ trace_store.append(boot_trace)
179
+
180
+ suggestions = analyze_traces(trace_store.recent(limit=100)) if run_onyx else []
181
+
182
+ from .sandbox import write_modal_source
183
+
184
+ modal_source_path = write_modal_source(plan)
185
+ dashboard_url = f"https://modal.com/apps/{plan.app_name}"
186
+
187
+ if dry_run:
188
+ result = DeployResult(
189
+ success=True,
190
+ plan=plan,
191
+ deployment_id=deployment_id,
192
+ status="planned",
193
+ message="Deployment plan generated. Modal deploy was not executed.",
194
+ dashboard_url=dashboard_url,
195
+ trace_path=str(trace_store.path),
196
+ modal_source_path=str(modal_source_path),
197
+ onyx_suggestions=suggestions,
198
+ )
199
+ write_local_deploy_record(result)
200
+ return result
201
+
202
+ from .sandbox import deploy_modal_app
203
+
204
+ modal_result = deploy_modal_app(plan)
205
+ result = DeployResult(
206
+ success=True,
207
+ plan=plan,
208
+ deployment_id=deployment_id,
209
+ status="deployed",
210
+ message="Agent deployed to Modal with Invoke tracing and persistence enabled.",
211
+ modal_app_name=modal_result.get("app_name", plan.app_name),
212
+ endpoint_url=modal_result.get("endpoint_url"),
213
+ dashboard_url=modal_result.get("dashboard_url", dashboard_url),
214
+ trace_path=str(trace_store.path),
215
+ modal_source_path=str(modal_source_path),
216
+ onyx_suggestions=suggestions,
217
+ )
218
+ write_local_deploy_record(result)
219
+ return result
220
+
221
+
222
+ def main() -> int:
223
+ import argparse
224
+
225
+ parser = argparse.ArgumentParser(description="Deploy a Claude Agent SDK project with Invoke.")
226
+ parser.add_argument("path", help="Agent project path.")
227
+ parser.add_argument("--app-name")
228
+ parser.add_argument("--dry-run", action="store_true")
229
+ parser.add_argument("--template", action="store_true", help="Scaffold a starter project at path.")
230
+ args = parser.parse_args()
231
+
232
+ if args.template:
233
+ path = copy_template(args.path)
234
+ print(f"Created Claude Agent SDK template at {path}")
235
+ return 0
236
+
237
+ result = deploy_claude_agent(args.path, app_name=args.app_name, dry_run=args.dry_run)
238
+ print(json.dumps(asdict(result), indent=2, sort_keys=True))
239
+ return 0
240
+
241
+
242
+ if __name__ == "__main__":
243
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Onyx Lite trace analyzer."""
2
+
3
+ from .analyzer import OnyxSuggestion, analyze_traces
4
+
5
+ __all__ = ["OnyxSuggestion", "analyze_traces"]
@@ -0,0 +1,75 @@
1
+ """Onyx Lite: trace analysis and concrete repair suggestions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class OnyxSuggestion:
12
+ title: str
13
+ severity: str
14
+ reason: str
15
+ apply: dict[str, Any] = field(default_factory=dict)
16
+
17
+
18
+ def _flatten_events(trace: dict[str, Any]) -> list[dict[str, Any]]:
19
+ events = trace.get("events") or trace.get("trace") or []
20
+ if isinstance(events, list):
21
+ return [event for event in events if isinstance(event, dict)]
22
+ return []
23
+
24
+
25
+ def analyze_traces(traces: list[dict[str, Any]]) -> list[OnyxSuggestion]:
26
+ """Detect repeated failures and emit safe, concrete fixes."""
27
+
28
+ if not traces:
29
+ return []
30
+
31
+ outcomes = Counter(str(trace.get("final_outcome") or trace.get("status") or "unknown") for trace in traces)
32
+ steps = Counter(event.get("step") for trace in traces for event in _flatten_events(trace) if event.get("step"))
33
+ suggestions: list[OnyxSuggestion] = []
34
+
35
+ if outcomes.get("timeout") or steps.get("tool_timeout"):
36
+ suggestions.append(
37
+ OnyxSuggestion(
38
+ title="Add reconciliation before retry",
39
+ severity="high",
40
+ reason="Recent traces include timeout patterns where the side effect may have succeeded.",
41
+ apply={"retry_strategy": "reconcile_before_retry", "unknown_effect": True},
42
+ )
43
+ )
44
+
45
+ if outcomes.get("idempotent_replay") or steps.get("duplicate_retry_detected"):
46
+ suggestions.append(
47
+ OnyxSuggestion(
48
+ title="Require idempotency keys for this action",
49
+ severity="medium",
50
+ reason="Duplicate retry patterns were detected in recent executions.",
51
+ apply={"idempotency": {"mode": "required", "key_fields": ["customer_id", "amount", "action"]}},
52
+ )
53
+ )
54
+
55
+ if outcomes.get("blocked_by_policy") or steps.get("policy_evaluated"):
56
+ suggestions.append(
57
+ OnyxSuggestion(
58
+ title="Move policy warning into preflight",
59
+ severity="medium",
60
+ reason="Policy blocked work after planning. Warn the agent before it attempts the action.",
61
+ apply={"preflight": {"policy_preview": True, "risk_threshold": "medium"}},
62
+ )
63
+ )
64
+
65
+ if not suggestions:
66
+ suggestions.append(
67
+ OnyxSuggestion(
68
+ title="No repeated failure pattern detected",
69
+ severity="info",
70
+ reason="Recent traces do not show recurring timeout, duplicate retry, or policy-block patterns.",
71
+ apply={},
72
+ )
73
+ )
74
+
75
+ return suggestions
@@ -0,0 +1,388 @@
1
+ """Modal sandbox generation for hosted Invoke agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import textwrap
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .deploy import DeployPlan
14
+
15
+
16
+ ENDPOINT_RE = re.compile(r"https://[^\s\"']+modal\.run[^\s\"']*")
17
+
18
+
19
+ def _require_modal_cli() -> None:
20
+ if shutil.which("modal"):
21
+ return
22
+ raise RuntimeError(
23
+ "Modal CLI is required for `invoke deploy` agent deployments. "
24
+ "Install and authenticate it with `pip install modal` and `modal setup`, "
25
+ "or run `invoke deploy --dry-run` to inspect the generated deployment first."
26
+ )
27
+
28
+
29
+ def _remote_entrypoint_command(entrypoint: str) -> str:
30
+ quoted = json.dumps(entrypoint)
31
+ return textwrap.dedent(
32
+ f"""\
33
+ def agent_command(prompt):
34
+ entrypoint = {quoted}
35
+ source = Path("/workspace") / entrypoint
36
+ if entrypoint.endswith(".ts"):
37
+ compiled = Path("/workspace/dist") / Path(entrypoint).with_suffix(".js").name
38
+ if compiled.exists():
39
+ return ["node", str(compiled), prompt]
40
+ return ["npx", "tsx", str(source), prompt]
41
+ return ["node", str(source), prompt]
42
+ """
43
+ )
44
+
45
+
46
+ def modal_source(plan: DeployPlan) -> str:
47
+ """Build the deployable Modal source file for one agent project."""
48
+
49
+ app_name = json.dumps(plan.app_name)
50
+ volume_name = json.dumps(plan.modal_volume)
51
+ project_root = json.dumps(plan.project_root)
52
+ env = json.dumps(plan.env, sort_keys=True)
53
+ endpoint_name = json.dumps(plan.endpoint_name)
54
+ agent_command_source = _remote_entrypoint_command(plan.entrypoint).rstrip()
55
+
56
+ template = textwrap.dedent(
57
+ f"""\
58
+ # Generated by Invoke. Do not edit by hand; rerun `invoke deploy`.
59
+ from __future__ import annotations
60
+
61
+ import json
62
+ import os
63
+ import subprocess
64
+ import time
65
+ import uuid
66
+ from pathlib import Path
67
+ from typing import Any
68
+
69
+ import modal
70
+ from fastapi import Request
71
+
72
+
73
+ APP_NAME = {app_name}
74
+ VOLUME_NAME = {volume_name}
75
+ PROJECT_ROOT = {project_root}
76
+ INVOKE_ENV = {env}
77
+
78
+ app = modal.App(APP_NAME)
79
+ volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
80
+
81
+ image = (
82
+ modal.Image.debian_slim(python_version="3.11")
83
+ .apt_install("nodejs", "npm")
84
+ .pip_install("fastapi[standard]")
85
+ .add_local_dir(
86
+ PROJECT_ROOT,
87
+ remote_path="/workspace",
88
+ copy=True,
89
+ ignore=[
90
+ ".git",
91
+ ".invoke",
92
+ "node_modules",
93
+ "dist",
94
+ ".next",
95
+ "__pycache__",
96
+ "*.tgz",
97
+ ],
98
+ )
99
+ .run_commands(
100
+ "cd /workspace && if [ -f package-lock.json ]; then npm ci; elif [ -f package.json ]; then npm install; fi",
101
+ "cd /workspace && if [ -f package.json ]; then npm run build --if-present; fi",
102
+ )
103
+ .env(INVOKE_ENV)
104
+ )
105
+
106
+
107
+ def utc_ms():
108
+ return int(time.time() * 1000)
109
+
110
+
111
+ def append_jsonl(path, payload):
112
+ path.parent.mkdir(parents=True, exist_ok=True)
113
+ with path.open("a", encoding="utf-8") as fh:
114
+ fh.write(json.dumps(payload, sort_keys=True) + "\\n")
115
+
116
+
117
+ def read_jsonl(path):
118
+ if not path.exists():
119
+ return []
120
+ rows = []
121
+ for line in path.read_text(encoding="utf-8").splitlines():
122
+ try:
123
+ rows.append(json.loads(line))
124
+ except json.JSONDecodeError:
125
+ continue
126
+ return rows
127
+
128
+
129
+ def evaluate_policy(action, params):
130
+ lowered = str(action).lower()
131
+ sql = str(params.get("sql", "")).lower() if isinstance(params, dict) else ""
132
+ if action == "database.execute" or "disable row level security" in sql or "drop table" in sql:
133
+ return {{
134
+ "effect": "block",
135
+ "risk": "high",
136
+ "reason": "Direct database execution can bypass application policy or row-level security.",
137
+ "guardrails": ["policy_block", "sandbox_required"],
138
+ }}
139
+ if any(word in lowered for word in ("delete", "refund", "charge", "transfer")):
140
+ return {{
141
+ "effect": "require_approval",
142
+ "risk": "high",
143
+ "reason": "Financial or destructive action requires approval and reconciliation.",
144
+ "guardrails": ["approval", "idempotency_key", "state_reconciliation"],
145
+ }}
146
+ return {{
147
+ "effect": "allow",
148
+ "risk": "low",
149
+ "reason": "No blocking policy matched.",
150
+ "guardrails": ["trace"],
151
+ }}
152
+
153
+
154
+ def reconcile_state(expected, live):
155
+ drift = []
156
+ if isinstance(expected, dict) and isinstance(live, dict):
157
+ for key, expected_value in expected.items():
158
+ if live.get(key) != expected_value:
159
+ drift.append({{"key": key, "expected": expected_value, "live": live.get(key)}})
160
+ return {{"status": "changed" if drift else "valid", "drift": drift, "checked_at_ms": utc_ms()}}
161
+
162
+
163
+ def extract_action(payload):
164
+ raw = payload.get("action") or {{}}
165
+ if isinstance(raw, str):
166
+ return raw, payload.get("params") or {{}}
167
+ if isinstance(raw, dict):
168
+ return raw.get("action") or raw.get("name") or "agent.run", raw.get("params") or {{}}
169
+ return "agent.run", {{}}
170
+
171
+
172
+ def checkpoint_path():
173
+ return Path("/state/checkpoints.jsonl")
174
+
175
+
176
+ def trace_path():
177
+ return Path("/state/traces.jsonl")
178
+
179
+
180
+ def freeze_execution(execution_id, action, params, context):
181
+ checkpoint = {{
182
+ "checkpoint_id": "freeze_" + uuid.uuid4().hex[:12],
183
+ "execution_id": execution_id,
184
+ "action": action,
185
+ "params": params,
186
+ "context_snapshot": context,
187
+ "created_at_ms": utc_ms(),
188
+ }}
189
+ append_jsonl(checkpoint_path(), checkpoint)
190
+ return checkpoint
191
+
192
+
193
+ def find_checkpoint(checkpoint_id):
194
+ for checkpoint in reversed(read_jsonl(checkpoint_path())):
195
+ if checkpoint.get("checkpoint_id") == checkpoint_id:
196
+ return checkpoint
197
+ return None
198
+
199
+
200
+ __AGENT_COMMAND_SOURCE__
201
+
202
+
203
+ @app.function(image=image, volumes={{"/state": volume}}, timeout=60 * 20)
204
+ @modal.fastapi_endpoint(method="POST", label={endpoint_name})
205
+ async def invoke(request: Request):
206
+ started = utc_ms()
207
+ payload = await request.json()
208
+ execution_id = payload.get("execution_id") or "exec_" + uuid.uuid4().hex[:12]
209
+ prompt = payload.get("prompt") or payload.get("input") or ""
210
+ agent_id = payload.get("agent_id") or APP_NAME
211
+ action, params = extract_action(payload)
212
+ events = []
213
+
214
+ def event(step, status, detail=None):
215
+ item = {{"step": step, "status": status, "timestamp_ms": utc_ms(), "detail": detail or {{}}}}
216
+ events.append(item)
217
+ return item
218
+
219
+ event("request_received", "ok", {{"agent_id": agent_id, "action": action}})
220
+
221
+ if not isinstance(params, dict):
222
+ event("schema_checked", "failed", {{"reason": "params must be an object"}})
223
+ trace = {{
224
+ "execution_id": execution_id,
225
+ "agent_id": agent_id,
226
+ "action": action,
227
+ "status": "blocked",
228
+ "risk": "medium",
229
+ "final_outcome": "blocked_by_schema",
230
+ "events": events,
231
+ "latency_ms": utc_ms() - started,
232
+ }}
233
+ append_jsonl(trace_path(), trace)
234
+ return {{"success": False, "execution": trace, "error": "params must be an object"}}
235
+ event("schema_checked", "ok")
236
+
237
+ if payload.get("resume_checkpoint_id"):
238
+ checkpoint = find_checkpoint(payload["resume_checkpoint_id"])
239
+ if not checkpoint:
240
+ event("checkpoint_loaded", "missing", {{"checkpoint_id": payload["resume_checkpoint_id"]}})
241
+ return {{"success": False, "execution_id": execution_id, "error": "checkpoint not found", "events": events}}
242
+ event("checkpoint_loaded", "ok", {{"checkpoint_id": checkpoint["checkpoint_id"]}})
243
+ revalidation = reconcile_state(checkpoint.get("context_snapshot") or {{}}, payload.get("live_context") or {{}})
244
+ event("state_revalidated", revalidation["status"], revalidation)
245
+ if revalidation["status"] == "changed":
246
+ trace = {{
247
+ "execution_id": execution_id,
248
+ "agent_id": agent_id,
249
+ "action": action,
250
+ "status": "requeued",
251
+ "risk": "high",
252
+ "final_outcome": "requeued_due_to_state_drift",
253
+ "events": events,
254
+ "latency_ms": utc_ms() - started,
255
+ }}
256
+ append_jsonl(trace_path(), trace)
257
+ return {{"success": True, "status": "requeued", "execution": trace}}
258
+
259
+ policy = evaluate_policy(action, params)
260
+ event("policy_evaluated", "ok", policy)
261
+ if policy["effect"] == "block":
262
+ trace = {{
263
+ "execution_id": execution_id,
264
+ "agent_id": agent_id,
265
+ "action": action,
266
+ "status": "blocked",
267
+ "risk": policy["risk"],
268
+ "policy": policy,
269
+ "final_outcome": "blocked_by_policy",
270
+ "events": events,
271
+ "latency_ms": utc_ms() - started,
272
+ }}
273
+ append_jsonl(trace_path(), trace)
274
+ return {{"success": True, "status": "blocked", "execution": trace, "certificate_returned": True}}
275
+
276
+ if policy["effect"] == "require_approval" and not payload.get("approved"):
277
+ checkpoint = freeze_execution(
278
+ execution_id,
279
+ action,
280
+ params,
281
+ payload.get("context_snapshot") or payload.get("expected_state") or {{}},
282
+ )
283
+ event("execution_frozen", "pending_approval", {{"checkpoint_id": checkpoint["checkpoint_id"]}})
284
+ trace = {{
285
+ "execution_id": execution_id,
286
+ "agent_id": agent_id,
287
+ "action": action,
288
+ "status": "pending_approval",
289
+ "risk": policy["risk"],
290
+ "policy": policy,
291
+ "checkpoint": checkpoint,
292
+ "final_outcome": "frozen_for_human_review",
293
+ "events": events,
294
+ "latency_ms": utc_ms() - started,
295
+ }}
296
+ append_jsonl(trace_path(), trace)
297
+ return {{"success": True, "status": "pending_approval", "execution": trace, "certificate_returned": True}}
298
+
299
+ if payload.get("expected_state") is not None or payload.get("live_state") is not None:
300
+ reconciliation = reconcile_state(payload.get("expected_state") or {{}}, payload.get("live_state") or {{}})
301
+ event("state_reconciled", reconciliation["status"], reconciliation)
302
+ if reconciliation["status"] == "changed":
303
+ trace = {{
304
+ "execution_id": execution_id,
305
+ "agent_id": agent_id,
306
+ "action": action,
307
+ "status": "blocked",
308
+ "risk": "high",
309
+ "policy": policy,
310
+ "reconciliation": reconciliation,
311
+ "final_outcome": "blocked_due_to_state_drift",
312
+ "events": events,
313
+ "latency_ms": utc_ms() - started,
314
+ }}
315
+ append_jsonl(trace_path(), trace)
316
+ return {{"success": True, "status": "blocked", "execution": trace, "certificate_returned": True}}
317
+
318
+ event("agent_execution_started", "ok")
319
+ completed = subprocess.run(
320
+ agent_command(str(prompt)),
321
+ cwd="/workspace",
322
+ text=True,
323
+ capture_output=True,
324
+ timeout=int(payload.get("timeout_seconds") or 60 * 15),
325
+ check=False,
326
+ )
327
+ status = "succeeded" if completed.returncode == 0 else "failed"
328
+ event("agent_execution_completed", status, {{"returncode": completed.returncode}})
329
+ trace = {{
330
+ "execution_id": execution_id,
331
+ "agent_id": agent_id,
332
+ "action": action,
333
+ "status": status,
334
+ "risk": policy["risk"],
335
+ "policy": policy,
336
+ "final_outcome": status,
337
+ "events": events,
338
+ "latency_ms": utc_ms() - started,
339
+ "stdout": completed.stdout[-8000:],
340
+ "stderr": completed.stderr[-8000:],
341
+ "certificate_returned": True,
342
+ }}
343
+ append_jsonl(trace_path(), trace)
344
+ return {{"success": completed.returncode == 0, "execution": trace, "certificate_returned": True}}
345
+ """
346
+ )
347
+ return template.replace("__AGENT_COMMAND_SOURCE__", agent_command_source)
348
+
349
+
350
+ def write_modal_source(plan: DeployPlan) -> Path:
351
+ state_dir = Path(plan.project_root) / ".invoke"
352
+ state_dir.mkdir(parents=True, exist_ok=True)
353
+ path = state_dir / "modal_app.py"
354
+ path.write_text(modal_source(plan), encoding="utf-8")
355
+ return path
356
+
357
+
358
+ def _parse_endpoint(output: str) -> str | None:
359
+ match = ENDPOINT_RE.search(output)
360
+ return match.group(0).rstrip(".,)")
361
+
362
+
363
+ def deploy_modal_app(plan: DeployPlan) -> dict[str, Any]:
364
+ """Deploy the generated Modal app and return endpoint metadata."""
365
+
366
+ _require_modal_cli()
367
+ source_path = write_modal_source(plan)
368
+ completed = subprocess.run(
369
+ ["modal", "deploy", str(source_path)],
370
+ cwd=plan.project_root,
371
+ text=True,
372
+ capture_output=True,
373
+ timeout=60 * 10,
374
+ check=False,
375
+ )
376
+ output = (completed.stdout or "") + "\n" + (completed.stderr or "")
377
+ if completed.returncode != 0:
378
+ raise RuntimeError(f"Modal deploy failed with exit code {completed.returncode}:\n{output.strip()}")
379
+
380
+ endpoint_url = _parse_endpoint(output)
381
+ return {
382
+ "app_name": plan.app_name,
383
+ "volume": plan.modal_volume,
384
+ "endpoint_url": endpoint_url,
385
+ "dashboard_url": f"https://modal.com/apps/{plan.app_name}",
386
+ "source_path": str(source_path),
387
+ "deploy_output": output.strip(),
388
+ }
@@ -0,0 +1,23 @@
1
+ """Supervision primitives for Invoke-hosted agents."""
2
+
3
+ from .monitor import (
4
+ Checkpoint,
5
+ ExecutionTrace,
6
+ PolicyDecision,
7
+ TraceEvent,
8
+ TraceStore,
9
+ build_boot_trace,
10
+ evaluate_policy,
11
+ reconcile_state,
12
+ )
13
+
14
+ __all__ = [
15
+ "Checkpoint",
16
+ "ExecutionTrace",
17
+ "PolicyDecision",
18
+ "TraceEvent",
19
+ "TraceStore",
20
+ "build_boot_trace",
21
+ "evaluate_policy",
22
+ "reconcile_state",
23
+ ]
@@ -0,0 +1,149 @@
1
+ """Structured tracing, policy checks, and freeze/thaw checkpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import json
7
+ import uuid
8
+ from dataclasses import asdict, dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ def utc_now() -> str:
14
+ return dt.datetime.now(dt.timezone.utc).isoformat()
15
+
16
+
17
+ @dataclass
18
+ class TraceEvent:
19
+ step: str
20
+ status: str
21
+ detail: dict[str, Any] = field(default_factory=dict)
22
+ timestamp: str = field(default_factory=utc_now)
23
+
24
+
25
+ @dataclass
26
+ class ExecutionTrace:
27
+ execution_id: str
28
+ agent_id: str
29
+ action: str
30
+ status: str
31
+ risk: str
32
+ events: list[TraceEvent] = field(default_factory=list)
33
+ final_outcome: str | None = None
34
+ created_at: str = field(default_factory=utc_now)
35
+
36
+
37
+ @dataclass
38
+ class PolicyDecision:
39
+ effect: str
40
+ risk: str
41
+ reason: str
42
+ requires_approval: bool = False
43
+ guardrails: list[str] = field(default_factory=list)
44
+
45
+
46
+ @dataclass
47
+ class Checkpoint:
48
+ checkpoint_id: str
49
+ execution_id: str
50
+ action: str
51
+ params: dict[str, Any]
52
+ context_snapshot: dict[str, Any]
53
+ created_at: str = field(default_factory=utc_now)
54
+
55
+
56
+ class TraceStore:
57
+ """Append-only JSONL trace store."""
58
+
59
+ def __init__(self, path: str | Path):
60
+ self.path = Path(path)
61
+ self.path.parent.mkdir(parents=True, exist_ok=True)
62
+
63
+ def append(self, trace: ExecutionTrace | dict[str, Any]) -> None:
64
+ payload = asdict(trace) if hasattr(trace, "__dataclass_fields__") else trace
65
+ with self.path.open("a", encoding="utf-8") as fh:
66
+ fh.write(json.dumps(payload, sort_keys=True) + "\n")
67
+
68
+ def recent(self, *, limit: int = 50) -> list[dict[str, Any]]:
69
+ if not self.path.exists():
70
+ return []
71
+ lines = self.path.read_text(encoding="utf-8").splitlines()[-limit:]
72
+ traces: list[dict[str, Any]] = []
73
+ for line in lines:
74
+ try:
75
+ traces.append(json.loads(line))
76
+ except json.JSONDecodeError:
77
+ continue
78
+ return traces
79
+
80
+
81
+ def evaluate_policy(action: str, params: dict[str, Any]) -> PolicyDecision:
82
+ lowered = action.lower()
83
+ sql = str(params.get("sql", "")).lower()
84
+ if action == "database.execute" or "disable row level security" in sql or "drop table" in sql:
85
+ return PolicyDecision(
86
+ effect="block",
87
+ risk="high",
88
+ reason="Direct database execution can bypass application policy or row-level security.",
89
+ guardrails=["policy_block", "sandbox_required"],
90
+ )
91
+ if any(word in lowered for word in ("delete", "refund", "charge", "transfer")):
92
+ return PolicyDecision(
93
+ effect="require_approval",
94
+ risk="high",
95
+ reason="Financial or destructive action requires approval and reconciliation.",
96
+ requires_approval=True,
97
+ guardrails=["approval", "idempotency_key", "state_reconciliation"],
98
+ )
99
+ return PolicyDecision(effect="allow", risk="low", reason="No blocking policy matched.", guardrails=["trace"])
100
+
101
+
102
+ def reconcile_state(expected: dict[str, Any], live: dict[str, Any]) -> dict[str, Any]:
103
+ drift = []
104
+ for key, expected_value in expected.items():
105
+ if live.get(key) != expected_value:
106
+ drift.append({"key": key, "expected": expected_value, "live": live.get(key)})
107
+ return {
108
+ "status": "changed" if drift else "valid",
109
+ "drift": drift,
110
+ "checked_at": utc_now(),
111
+ }
112
+
113
+
114
+ def freeze_execution(execution_id: str, action: str, params: dict[str, Any], context: dict[str, Any]) -> Checkpoint:
115
+ return Checkpoint(
116
+ checkpoint_id="freeze_" + uuid.uuid4().hex[:12],
117
+ execution_id=execution_id,
118
+ action=action,
119
+ params=params,
120
+ context_snapshot=context,
121
+ )
122
+
123
+
124
+ def thaw_checkpoint(checkpoint: Checkpoint, live_context: dict[str, Any]) -> dict[str, Any]:
125
+ return {
126
+ "checkpoint_id": checkpoint.checkpoint_id,
127
+ "execution_id": checkpoint.execution_id,
128
+ "revalidation": reconcile_state(checkpoint.context_snapshot, live_context),
129
+ "thawed_at": utc_now(),
130
+ }
131
+
132
+
133
+ def build_boot_trace(app_name: str, entrypoint: str, robustness: dict[str, bool]) -> ExecutionTrace:
134
+ trace = ExecutionTrace(
135
+ execution_id="exec_" + uuid.uuid4().hex[:12],
136
+ agent_id=app_name,
137
+ action="invoke.deploy",
138
+ status="planned",
139
+ risk="low",
140
+ final_outcome="deployment_plan_recorded",
141
+ )
142
+ trace.events.extend(
143
+ [
144
+ TraceEvent("project_loaded", "ok", {"entrypoint": entrypoint}),
145
+ TraceEvent("persistence_selected", "ok", {"kind": "modal_volume"}),
146
+ TraceEvent("robustness_layer_enabled", "ok", robustness),
147
+ ]
148
+ )
149
+ return trace
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "my-claude-agent",
3
+ "entrypoint": "src/index.ts",
4
+ "runtime": "node",
5
+ "tools": [],
6
+ "policy": {
7
+ "approval_required_for": ["charge", "refund", "delete", "database.execute"],
8
+ "block": ["database.execute"]
9
+ }
10
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "invoke-claude-agent",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsx src/index.ts",
8
+ "start": "node dist/index.js",
9
+ "build": "tsc -p tsconfig.json"
10
+ },
11
+ "dependencies": {
12
+ "@anthropic-ai/claude-agent-sdk": "latest"
13
+ },
14
+ "devDependencies": {
15
+ "tsx": "latest",
16
+ "typescript": "latest"
17
+ }
18
+ }
@@ -0,0 +1,14 @@
1
+ import { query } from "@anthropic-ai/claude-agent-sdk"
2
+
3
+ const prompt = process.argv.slice(2).join(" ") || "Summarize what this agent can do."
4
+
5
+ for await (const message of query({
6
+ prompt,
7
+ options: {
8
+ allowedTools: [],
9
+ },
10
+ })) {
11
+ if ("result" in message) {
12
+ console.log(message.result)
13
+ }
14
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "outDir": "dist",
8
+ "skipLibCheck": true
9
+ },
10
+ "include": ["src/**/*.ts"]
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invokehq/cli",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "CLI for Invoke, execution reliability infrastructure for AI agents.",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -10,6 +10,13 @@
10
10
  "files": [
11
11
  "agentify.py",
12
12
  "bin",
13
+ "invoke/*.py",
14
+ "invoke/onyx/*.py",
15
+ "invoke/supervisor/*.py",
16
+ "invoke/templates/claude-agent-sdk/invoke.json",
17
+ "invoke/templates/claude-agent-sdk/package.json",
18
+ "invoke/templates/claude-agent-sdk/src/index.ts",
19
+ "invoke/templates/claude-agent-sdk/tsconfig.json",
13
20
  "README.md",
14
21
  "pyproject.toml"
15
22
  ],
package/pyproject.toml CHANGED
@@ -24,5 +24,9 @@ dev = ["build", "twine"]
24
24
  invoke = "agentify:main"
25
25
  agentify = "agentify:main"
26
26
 
27
- [tool.setuptools.py-modules]
28
- modules = ["agentify"]
27
+ [tool.setuptools]
28
+ py-modules = ["agentify"]
29
+ packages = ["invoke", "invoke.onyx", "invoke.supervisor"]
30
+
31
+ [tool.setuptools.package-data]
32
+ invoke = ["templates/claude-agent-sdk/*", "templates/claude-agent-sdk/src/*"]