@invokehq/cli 0.2.6 → 0.2.7
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/agentify.py +60 -1
- package/invoke/__init__.py +5 -0
- package/invoke/deploy.py +229 -0
- package/invoke/onyx/__init__.py +5 -0
- package/invoke/onyx/analyzer.py +75 -0
- package/invoke/sandbox.py +106 -0
- package/invoke/supervisor/__init__.py +23 -0
- package/invoke/supervisor/monitor.py +149 -0
- package/invoke/templates/claude-agent-sdk/invoke.json +10 -0
- package/invoke/templates/claude-agent-sdk/package.json +18 -0
- package/invoke/templates/claude-agent-sdk/src/index.ts +14 -0
- package/invoke/templates/claude-agent-sdk/tsconfig.json +11 -0
- package/package.json +8 -1
- package/pyproject.toml +6 -2
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,25 @@ 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(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
|
+
return bool(config.get("entrypoint")) and (
|
|
1243
|
+
runtime in {"node", "python", "claude-agent-sdk"}
|
|
1244
|
+
or agent_type in {"agent", "claude-agent", "claude-agent-sdk"}
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
|
|
1217
1248
|
def project_mcp_url(root: Path, config: dict[str, Any], explicit_mcp_url: str | None = None) -> str | None:
|
|
1218
1249
|
if explicit_mcp_url:
|
|
1219
1250
|
return explicit_mcp_url
|
|
@@ -1371,6 +1402,34 @@ def save_deployment(record: dict[str, Any]) -> None:
|
|
|
1371
1402
|
|
|
1372
1403
|
def deploy_command(args: argparse.Namespace) -> int:
|
|
1373
1404
|
root = Path(args.path)
|
|
1405
|
+
raw_config = read_project_config(root)
|
|
1406
|
+
if is_agent_project(raw_config):
|
|
1407
|
+
from dataclasses import asdict
|
|
1408
|
+
|
|
1409
|
+
from invoke.deploy import deploy_claude_agent
|
|
1410
|
+
|
|
1411
|
+
result = deploy_claude_agent(
|
|
1412
|
+
root,
|
|
1413
|
+
app_name=args.slug or raw_config.get("slug") or raw_config.get("name"),
|
|
1414
|
+
dry_run=args.dry_run,
|
|
1415
|
+
)
|
|
1416
|
+
record = {
|
|
1417
|
+
"project": str(root.resolve()),
|
|
1418
|
+
"name": result.plan.app_name,
|
|
1419
|
+
"provider_id": result.deployment_id,
|
|
1420
|
+
"slug": result.plan.app_name,
|
|
1421
|
+
"gateway_url": result.modal_app_name,
|
|
1422
|
+
"base_url": "modal",
|
|
1423
|
+
"tools": ["agent.run"],
|
|
1424
|
+
"deployed_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
1425
|
+
"mode": "modal_agent",
|
|
1426
|
+
"trace_path": result.trace_path,
|
|
1427
|
+
}
|
|
1428
|
+
if not args.dry_run:
|
|
1429
|
+
save_deployment(record)
|
|
1430
|
+
print(json.dumps({"success": result.success, "mode": "modal_agent", **asdict(result)}, indent=2))
|
|
1431
|
+
return 0
|
|
1432
|
+
|
|
1374
1433
|
config = read_project(root)
|
|
1375
1434
|
tools = config["tools"]
|
|
1376
1435
|
mcp_url = project_mcp_url(root, config, args.mcp_url)
|
|
@@ -1773,7 +1832,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1773
1832
|
|
|
1774
1833
|
init = subparsers.add_parser("init", help="Scaffold an Invoke project.")
|
|
1775
1834
|
init.add_argument("name", help="Project directory/name.")
|
|
1776
|
-
init.add_argument("--template", choices=["default", "linear", "crm-guardrail"], default="default")
|
|
1835
|
+
init.add_argument("--template", choices=["default", "linear", "crm-guardrail", "claude-agent"], default="default")
|
|
1777
1836
|
init.add_argument("--force", action="store_true", help="Write into an existing non-empty directory.")
|
|
1778
1837
|
init.set_defaults(func=init_command)
|
|
1779
1838
|
|
package/invoke/deploy.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
tracing_enabled: bool = True
|
|
38
|
+
persistence: dict[str, str] = field(default_factory=dict)
|
|
39
|
+
robustness: dict[str, bool] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class DeployResult:
|
|
44
|
+
"""Result returned by a deploy attempt or dry run."""
|
|
45
|
+
|
|
46
|
+
success: bool
|
|
47
|
+
plan: DeployPlan
|
|
48
|
+
deployment_id: str
|
|
49
|
+
status: str
|
|
50
|
+
message: str
|
|
51
|
+
modal_app_name: str | None = None
|
|
52
|
+
trace_path: str | None = None
|
|
53
|
+
onyx_suggestions: list[OnyxSuggestion] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def slugify(value: str) -> str:
|
|
57
|
+
cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in value)
|
|
58
|
+
return "-".join(part for part in cleaned.split("-") if part) or "invoke-agent"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def now_iso() -> str:
|
|
62
|
+
return dt.datetime.now(dt.timezone.utc).isoformat()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_project_config(project_root: Path) -> dict[str, Any]:
|
|
66
|
+
for name in ("invoke.json", "agent.json", "package.json"):
|
|
67
|
+
path = project_root / name
|
|
68
|
+
if path.exists():
|
|
69
|
+
try:
|
|
70
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
71
|
+
except json.JSONDecodeError as exc:
|
|
72
|
+
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
|
|
73
|
+
if isinstance(data, dict):
|
|
74
|
+
return data
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def infer_entrypoint(project_root: Path, config: dict[str, Any]) -> str:
|
|
79
|
+
configured = config.get("entrypoint") or config.get("main")
|
|
80
|
+
candidates = (str(configured),) if configured else DEFAULT_ENTRYPOINTS
|
|
81
|
+
for candidate in candidates:
|
|
82
|
+
if candidate and (project_root / candidate).exists():
|
|
83
|
+
return candidate
|
|
84
|
+
raise FileNotFoundError(
|
|
85
|
+
"Could not find an agent entrypoint. Expected one of: "
|
|
86
|
+
+ ", ".join(DEFAULT_ENTRYPOINTS)
|
|
87
|
+
+ ". You can also set `entrypoint` in invoke.json."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_deploy_plan(project_path: str | Path, *, app_name: str | None = None) -> DeployPlan:
|
|
92
|
+
project_root = Path(project_path).expanduser().resolve()
|
|
93
|
+
if not project_root.exists() or not project_root.is_dir():
|
|
94
|
+
raise FileNotFoundError(f"Agent project does not exist: {project_root}")
|
|
95
|
+
|
|
96
|
+
config = load_project_config(project_root)
|
|
97
|
+
inferred_name = app_name or config.get("name") or project_root.name
|
|
98
|
+
slug = slugify(str(inferred_name))
|
|
99
|
+
entrypoint = infer_entrypoint(project_root, config)
|
|
100
|
+
|
|
101
|
+
env_keys = [
|
|
102
|
+
"ANTHROPIC_API_KEY",
|
|
103
|
+
"OPENAI_API_KEY",
|
|
104
|
+
"INVOKE_API_KEY",
|
|
105
|
+
"INVOKE_BASE_URL",
|
|
106
|
+
]
|
|
107
|
+
env = {key: os.environ[key] for key in env_keys if os.environ.get(key)}
|
|
108
|
+
|
|
109
|
+
return DeployPlan(
|
|
110
|
+
project_root=str(project_root),
|
|
111
|
+
app_name=slug,
|
|
112
|
+
entrypoint=entrypoint,
|
|
113
|
+
modal_volume=f"{slug}-state",
|
|
114
|
+
env=env,
|
|
115
|
+
persistence={
|
|
116
|
+
"kind": "modal_volume",
|
|
117
|
+
"mount_path": "/state",
|
|
118
|
+
"trace_path": "/state/traces.jsonl",
|
|
119
|
+
"checkpoint_path": "/state/checkpoints.jsonl",
|
|
120
|
+
},
|
|
121
|
+
robustness={
|
|
122
|
+
"schema_checks": True,
|
|
123
|
+
"policy_checks": True,
|
|
124
|
+
"state_reconciliation": True,
|
|
125
|
+
"freeze_thaw_hitl": True,
|
|
126
|
+
"structured_tracing": True,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def write_local_deploy_record(result: DeployResult) -> Path:
|
|
132
|
+
root = Path(result.plan.project_root)
|
|
133
|
+
state_dir = root / ".invoke"
|
|
134
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
path = state_dir / "deployment.json"
|
|
136
|
+
path.write_text(json.dumps(asdict(result), indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
137
|
+
return path
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def copy_template(destination: str | Path, *, force: bool = False) -> Path:
|
|
141
|
+
"""Scaffold a Claude Agent SDK starter project."""
|
|
142
|
+
|
|
143
|
+
destination = Path(destination).expanduser().resolve()
|
|
144
|
+
template_root = Path(__file__).with_name("templates") / "claude-agent-sdk"
|
|
145
|
+
if destination.exists() and any(destination.iterdir()) and not force:
|
|
146
|
+
raise FileExistsError(f"{destination} is not empty. Pass force=True to overwrite template files.")
|
|
147
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
for source in template_root.rglob("*"):
|
|
149
|
+
target = destination / source.relative_to(template_root)
|
|
150
|
+
if source.is_dir():
|
|
151
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
continue
|
|
153
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
if target.exists() and not force:
|
|
155
|
+
continue
|
|
156
|
+
shutil.copyfile(source, target)
|
|
157
|
+
return destination
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def deploy_claude_agent(
|
|
161
|
+
project_path: str | Path,
|
|
162
|
+
*,
|
|
163
|
+
app_name: str | None = None,
|
|
164
|
+
dry_run: bool = False,
|
|
165
|
+
run_onyx: bool = True,
|
|
166
|
+
) -> DeployResult:
|
|
167
|
+
"""Build and optionally deploy a Claude Agent SDK project to Modal."""
|
|
168
|
+
|
|
169
|
+
plan = build_deploy_plan(project_path, app_name=app_name)
|
|
170
|
+
deployment_id = "dep_" + dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
171
|
+
|
|
172
|
+
trace_store = TraceStore(Path(plan.project_root) / ".invoke" / "traces.jsonl")
|
|
173
|
+
boot_trace = build_boot_trace(plan.app_name, plan.entrypoint, plan.robustness)
|
|
174
|
+
trace_store.append(boot_trace)
|
|
175
|
+
|
|
176
|
+
suggestions = analyze_traces(trace_store.recent(limit=100)) if run_onyx else []
|
|
177
|
+
|
|
178
|
+
if dry_run:
|
|
179
|
+
result = DeployResult(
|
|
180
|
+
success=True,
|
|
181
|
+
plan=plan,
|
|
182
|
+
deployment_id=deployment_id,
|
|
183
|
+
status="planned",
|
|
184
|
+
message="Deployment plan generated. Modal deploy was not executed.",
|
|
185
|
+
trace_path=str(trace_store.path),
|
|
186
|
+
onyx_suggestions=suggestions,
|
|
187
|
+
)
|
|
188
|
+
write_local_deploy_record(result)
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
from .sandbox import deploy_modal_app
|
|
192
|
+
|
|
193
|
+
modal_result = deploy_modal_app(plan)
|
|
194
|
+
result = DeployResult(
|
|
195
|
+
success=True,
|
|
196
|
+
plan=plan,
|
|
197
|
+
deployment_id=deployment_id,
|
|
198
|
+
status="deployed",
|
|
199
|
+
message="Agent deployed to Modal with Invoke tracing and persistence enabled.",
|
|
200
|
+
modal_app_name=modal_result.get("app_name", plan.app_name),
|
|
201
|
+
trace_path=str(trace_store.path),
|
|
202
|
+
onyx_suggestions=suggestions,
|
|
203
|
+
)
|
|
204
|
+
write_local_deploy_record(result)
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main() -> int:
|
|
209
|
+
import argparse
|
|
210
|
+
|
|
211
|
+
parser = argparse.ArgumentParser(description="Deploy a Claude Agent SDK project with Invoke.")
|
|
212
|
+
parser.add_argument("path", help="Agent project path.")
|
|
213
|
+
parser.add_argument("--app-name")
|
|
214
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
215
|
+
parser.add_argument("--template", action="store_true", help="Scaffold a starter project at path.")
|
|
216
|
+
args = parser.parse_args()
|
|
217
|
+
|
|
218
|
+
if args.template:
|
|
219
|
+
path = copy_template(args.path)
|
|
220
|
+
print(f"Created Claude Agent SDK template at {path}")
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
result = deploy_claude_agent(args.path, app_name=args.app_name, dry_run=args.dry_run)
|
|
224
|
+
print(json.dumps(asdict(result), indent=2, sort_keys=True))
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
raise SystemExit(main())
|
|
@@ -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,106 @@
|
|
|
1
|
+
"""Modal sandbox definitions for hosted Invoke agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .deploy import DeployPlan
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _import_modal():
|
|
13
|
+
try:
|
|
14
|
+
import modal # type: ignore
|
|
15
|
+
except ImportError as exc:
|
|
16
|
+
raise RuntimeError(
|
|
17
|
+
"Modal is required for hosted agent deployment. Install it with `pip install modal` "
|
|
18
|
+
"and run `modal setup`, or use `--dry-run` to inspect the deployment plan."
|
|
19
|
+
) from exc
|
|
20
|
+
return modal
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def modal_objects(plan: DeployPlan):
|
|
24
|
+
"""Return Modal app/image/volume objects for a deployment plan."""
|
|
25
|
+
|
|
26
|
+
modal = _import_modal()
|
|
27
|
+
app = modal.App(plan.app_name)
|
|
28
|
+
image = (
|
|
29
|
+
modal.Image.debian_slim(python_version="3.11")
|
|
30
|
+
.apt_install("nodejs", "npm")
|
|
31
|
+
.pip_install("anthropic", "httpx")
|
|
32
|
+
.env(plan.env)
|
|
33
|
+
)
|
|
34
|
+
volume = modal.Volume.from_name(plan.modal_volume, create_if_missing=True)
|
|
35
|
+
return modal, app, image, volume
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def deploy_modal_app(plan: DeployPlan) -> dict[str, Any]:
|
|
39
|
+
"""Deploy the Modal app skeleton for one Invoke agent.
|
|
40
|
+
|
|
41
|
+
The actual Modal CLI invocation happens outside this module. This function
|
|
42
|
+
validates that Modal is importable and returns the deployable object names,
|
|
43
|
+
which lets the Python CLI remain deterministic in tests.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
modal, app, image, volume = modal_objects(plan)
|
|
47
|
+
project_root = Path(plan.project_root)
|
|
48
|
+
entrypoint = plan.entrypoint
|
|
49
|
+
|
|
50
|
+
@app.function(
|
|
51
|
+
image=image,
|
|
52
|
+
volumes={"/state": volume},
|
|
53
|
+
timeout=60 * 20,
|
|
54
|
+
secrets=[],
|
|
55
|
+
)
|
|
56
|
+
def run_agent(prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
57
|
+
"""Run one agent task inside the Modal sandbox."""
|
|
58
|
+
|
|
59
|
+
import json
|
|
60
|
+
import subprocess
|
|
61
|
+
import time
|
|
62
|
+
import uuid
|
|
63
|
+
|
|
64
|
+
execution_id = "exec_" + uuid.uuid4().hex[:12]
|
|
65
|
+
started_at = time.time()
|
|
66
|
+
trace = {
|
|
67
|
+
"execution_id": execution_id,
|
|
68
|
+
"event": "agent_run_started",
|
|
69
|
+
"prompt": prompt,
|
|
70
|
+
"context": context or {},
|
|
71
|
+
"entrypoint": entrypoint,
|
|
72
|
+
"started_at": started_at,
|
|
73
|
+
}
|
|
74
|
+
with open("/state/traces.jsonl", "a", encoding="utf-8") as fh:
|
|
75
|
+
fh.write(json.dumps(trace, sort_keys=True) + "\n")
|
|
76
|
+
|
|
77
|
+
command = ["node", entrypoint, prompt]
|
|
78
|
+
completed = subprocess.run(
|
|
79
|
+
command,
|
|
80
|
+
cwd="/workspace",
|
|
81
|
+
text=True,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
timeout=60 * 15,
|
|
84
|
+
check=False,
|
|
85
|
+
)
|
|
86
|
+
final = {
|
|
87
|
+
"execution_id": execution_id,
|
|
88
|
+
"event": "agent_run_completed",
|
|
89
|
+
"returncode": completed.returncode,
|
|
90
|
+
"stdout": completed.stdout[-8000:],
|
|
91
|
+
"stderr": completed.stderr[-8000:],
|
|
92
|
+
"latency_ms": round((time.time() - started_at) * 1000, 2),
|
|
93
|
+
}
|
|
94
|
+
with open("/state/traces.jsonl", "a", encoding="utf-8") as fh:
|
|
95
|
+
fh.write(json.dumps(final, sort_keys=True) + "\n")
|
|
96
|
+
return final
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"app_name": plan.app_name,
|
|
100
|
+
"volume": plan.modal_volume,
|
|
101
|
+
"entrypoint": plan.entrypoint,
|
|
102
|
+
"project_root": str(project_root),
|
|
103
|
+
"plan": asdict(plan),
|
|
104
|
+
"modal_app": app,
|
|
105
|
+
"modal_run_function": run_agent,
|
|
106
|
+
}
|
|
@@ -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,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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invokehq/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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
|
|
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/*"]
|