skillstate-kit 0.1.1__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.
- skillstate/__init__.py +35 -0
- skillstate/__main__.py +3 -0
- skillstate/artifacts.py +55 -0
- skillstate/cli.py +256 -0
- skillstate/compiler.py +367 -0
- skillstate/demo.py +74 -0
- skillstate/errors.py +33 -0
- skillstate/hosts.py +322 -0
- skillstate/jsonio.py +143 -0
- skillstate/mcp_server.py +142 -0
- skillstate/models.py +88 -0
- skillstate/providers.py +89 -0
- skillstate/py.typed +0 -0
- skillstate/runtime.py +179 -0
- skillstate/schema.py +91 -0
- skillstate/service.py +59 -0
- skillstate/store.py +332 -0
- skillstate_kit-0.1.1.dist-info/METADATA +157 -0
- skillstate_kit-0.1.1.dist-info/RECORD +22 -0
- skillstate_kit-0.1.1.dist-info/WHEEL +4 -0
- skillstate_kit-0.1.1.dist-info/entry_points.txt +2 -0
- skillstate_kit-0.1.1.dist-info/licenses/LICENSE +21 -0
skillstate/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Portable state for agent skills. Imports never start models or modify hosts."""
|
|
2
|
+
|
|
3
|
+
from .errors import (
|
|
4
|
+
BudgetExceeded,
|
|
5
|
+
ConflictError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
OperationUncertain,
|
|
8
|
+
SkillStateError,
|
|
9
|
+
SourceChanged,
|
|
10
|
+
StepLimitExceeded,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
from .models import Limits, Skill, Tool, ToolResult
|
|
14
|
+
from .runtime import SkillRuntime
|
|
15
|
+
from .schema import apply_patch
|
|
16
|
+
from .store import SQLiteStore
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.1"
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BudgetExceeded",
|
|
21
|
+
"ConflictError",
|
|
22
|
+
"Limits",
|
|
23
|
+
"NotFoundError",
|
|
24
|
+
"OperationUncertain",
|
|
25
|
+
"Skill",
|
|
26
|
+
"SkillStateError",
|
|
27
|
+
"SourceChanged",
|
|
28
|
+
"StepLimitExceeded",
|
|
29
|
+
"Tool",
|
|
30
|
+
"ToolResult",
|
|
31
|
+
"ValidationError",
|
|
32
|
+
"apply_patch",
|
|
33
|
+
"SkillRuntime",
|
|
34
|
+
"SQLiteStore",
|
|
35
|
+
]
|
skillstate/__main__.py
ADDED
skillstate/artifacts.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Content-addressed artifacts, separate from the model's execution context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .errors import BudgetExceeded, NotFoundError, ValidationError
|
|
9
|
+
from .jsonio import atomic_write, digest, within
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ArtifactStore:
|
|
13
|
+
def __init__(self, project: Path):
|
|
14
|
+
self.root = within(project, ".skillstate/local/artifacts")
|
|
15
|
+
|
|
16
|
+
def put(self, content: str) -> str:
|
|
17
|
+
if type(content) is not str:
|
|
18
|
+
raise ValidationError("Artifact content must be text")
|
|
19
|
+
raw = content.encode("utf-8")
|
|
20
|
+
if len(raw) > 2_000_000:
|
|
21
|
+
raise BudgetExceeded("Artifact exceeds 2000000 bytes")
|
|
22
|
+
key = digest(raw)
|
|
23
|
+
path = within(self.root, key + ".txt")
|
|
24
|
+
if path.exists() and digest(path.read_bytes()) != key:
|
|
25
|
+
raise ValidationError("Existing artifact integrity check failed")
|
|
26
|
+
if not path.exists():
|
|
27
|
+
atomic_write(path, raw)
|
|
28
|
+
return key
|
|
29
|
+
|
|
30
|
+
def read(self, key: str, offset: int = 0, length: int = 4000) -> dict:
|
|
31
|
+
if type(key) is not str or not re.fullmatch(r"[a-f0-9]{64}", key):
|
|
32
|
+
raise ValidationError("Invalid artifact ID")
|
|
33
|
+
if (
|
|
34
|
+
type(offset) is not int
|
|
35
|
+
or offset < 0
|
|
36
|
+
or type(length) is not int
|
|
37
|
+
or not 1 <= length <= 8000
|
|
38
|
+
):
|
|
39
|
+
raise ValidationError("Invalid artifact character range")
|
|
40
|
+
path = within(self.root, key + ".txt")
|
|
41
|
+
if not path.is_file():
|
|
42
|
+
raise NotFoundError("Artifact not found")
|
|
43
|
+
if path.stat().st_size > 2_000_000:
|
|
44
|
+
raise BudgetExceeded("Artifact exceeds storage budget")
|
|
45
|
+
raw = path.read_bytes()
|
|
46
|
+
if digest(raw) != key:
|
|
47
|
+
raise ValidationError("Artifact integrity check failed")
|
|
48
|
+
content = raw.decode("utf-8")
|
|
49
|
+
return {
|
|
50
|
+
"id": key,
|
|
51
|
+
"offset": offset,
|
|
52
|
+
"content": content[offset : offset + length],
|
|
53
|
+
"next_offset": min(offset + length, len(content)),
|
|
54
|
+
"total_characters": len(content),
|
|
55
|
+
}
|
skillstate/cli.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""JSON-first command line interface for people and coding agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sqlite3
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__, compiler, hosts
|
|
14
|
+
from .artifacts import ArtifactStore
|
|
15
|
+
from .demo import run_demo
|
|
16
|
+
from .errors import SkillStateError, ValidationError
|
|
17
|
+
from .jsonio import dumps, read_json, within
|
|
18
|
+
from .service import ProjectService
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parser() -> argparse.ArgumentParser:
|
|
22
|
+
p = argparse.ArgumentParser(
|
|
23
|
+
prog="skillstate", description="Portable, validated execution state for agent skills"
|
|
24
|
+
)
|
|
25
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
26
|
+
p.add_argument(
|
|
27
|
+
"--project", type=Path, default=Path.cwd(), help="Project root (before the subcommand)"
|
|
28
|
+
)
|
|
29
|
+
subs = p.add_subparsers(dest="command", required=True)
|
|
30
|
+
init = subs.add_parser("init", help="Install project-scoped generator skills")
|
|
31
|
+
init.add_argument("--host", action="append", choices=hosts.HOSTS)
|
|
32
|
+
init.add_argument(
|
|
33
|
+
"--mcp",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="Also merge local MCP configurations (requires mcp extra)",
|
|
36
|
+
)
|
|
37
|
+
gen = subs.add_parser(
|
|
38
|
+
"generate", help="Convert a source skill or prepare a semantic generation request"
|
|
39
|
+
)
|
|
40
|
+
gen.add_argument("source", nargs="?", default=".")
|
|
41
|
+
gen.add_argument("--name")
|
|
42
|
+
gen.add_argument("--profile", choices=("auto", "tracking", "python-tests"), default="auto")
|
|
43
|
+
mode = gen.add_mutually_exclusive_group()
|
|
44
|
+
mode.add_argument("--prepare", action="store_true")
|
|
45
|
+
mode.add_argument("--proposal", type=Path)
|
|
46
|
+
mode.add_argument("--base-url", help="Configured JSON chat endpoint, including /v1 if required")
|
|
47
|
+
gen.add_argument("--model")
|
|
48
|
+
gen.add_argument("--api-key-env", default="SKILLSTATE_API_KEY")
|
|
49
|
+
gen.add_argument("--source-hash", help="Required with --proposal")
|
|
50
|
+
gen.add_argument("--install", action="store_true")
|
|
51
|
+
check = subs.add_parser(
|
|
52
|
+
"validate", help="Validate bundle integrity, schema and source freshness"
|
|
53
|
+
)
|
|
54
|
+
check.add_argument("name")
|
|
55
|
+
doctor = subs.add_parser(
|
|
56
|
+
"doctor", help="Check managed files and local integration configuration"
|
|
57
|
+
)
|
|
58
|
+
doctor.add_argument(
|
|
59
|
+
"--mcp", action="store_true", help="Also perform a real local MCP handshake"
|
|
60
|
+
)
|
|
61
|
+
subs.add_parser("uninstall", help="Remove unmodified managed host files; retain run data")
|
|
62
|
+
subs.add_parser("status", help="List local runs")
|
|
63
|
+
subs.add_parser("demo", help="Run the explicit offline scripted-model example")
|
|
64
|
+
subs.add_parser("serve", help="Start a project-scoped STDIO MCP server")
|
|
65
|
+
run = subs.add_parser("run", help="Operate a durable run")
|
|
66
|
+
r = run.add_subparsers(dest="operation", required=True)
|
|
67
|
+
open_p = r.add_parser("open")
|
|
68
|
+
open_p.add_argument("name")
|
|
69
|
+
open_p.add_argument("--owner", required=True)
|
|
70
|
+
open_p.add_argument("--id")
|
|
71
|
+
open_p.add_argument("--observation", type=Path)
|
|
72
|
+
for command in ("context", "events"):
|
|
73
|
+
child = r.add_parser(command)
|
|
74
|
+
child.add_argument("run_id")
|
|
75
|
+
if command == "events":
|
|
76
|
+
child.add_argument("--after", type=int, default=0)
|
|
77
|
+
update = r.add_parser("update")
|
|
78
|
+
reserve = r.add_parser("reserve")
|
|
79
|
+
handoff = r.add_parser("handoff")
|
|
80
|
+
for child in (update, reserve, handoff):
|
|
81
|
+
child.add_argument("run_id")
|
|
82
|
+
child.add_argument("--owner", required=True)
|
|
83
|
+
child.add_argument("--revision", type=int, required=True)
|
|
84
|
+
update.add_argument("--patch", type=Path, required=True)
|
|
85
|
+
update.add_argument("--observation", type=Path)
|
|
86
|
+
update.add_argument("--done", action="store_true")
|
|
87
|
+
reserve.add_argument("--decision", type=Path, required=True)
|
|
88
|
+
handoff.add_argument("--to", required=True)
|
|
89
|
+
result = r.add_parser("result")
|
|
90
|
+
unknown = r.add_parser("unknown")
|
|
91
|
+
for child in (result, unknown):
|
|
92
|
+
child.add_argument("operation_id")
|
|
93
|
+
child.add_argument("--owner", required=True)
|
|
94
|
+
result.add_argument("--result", type=Path, required=True)
|
|
95
|
+
result.add_argument("--reconcile", action="store_true")
|
|
96
|
+
unknown.add_argument("--reason", default="Host could not determine the tool outcome")
|
|
97
|
+
artifact = subs.add_parser("artifact")
|
|
98
|
+
a = artifact.add_subparsers(dest="operation", required=True)
|
|
99
|
+
put = a.add_parser("put")
|
|
100
|
+
put.add_argument("file", type=Path)
|
|
101
|
+
read = a.add_parser("read")
|
|
102
|
+
read.add_argument("id")
|
|
103
|
+
read.add_argument("--offset", type=int, default=0)
|
|
104
|
+
read.add_argument("--length", type=int, default=4000)
|
|
105
|
+
return p
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def dispatch(args) -> dict | list | None:
|
|
109
|
+
project = args.project.resolve()
|
|
110
|
+
service = ProjectService(project)
|
|
111
|
+
|
|
112
|
+
def read(path):
|
|
113
|
+
return read_json(within(project, path)) if path else None
|
|
114
|
+
|
|
115
|
+
if args.command == "init":
|
|
116
|
+
return hosts.install(project, args.host, mcp=args.mcp)
|
|
117
|
+
if args.command == "generate":
|
|
118
|
+
if args.prepare:
|
|
119
|
+
return compiler.prepare(project, args.source, args.name)
|
|
120
|
+
proposal, source_hash = read(args.proposal), args.source_hash
|
|
121
|
+
if proposal is not None and not source_hash:
|
|
122
|
+
raise ValidationError("--proposal requires --source-hash from the prepare response")
|
|
123
|
+
if args.base_url:
|
|
124
|
+
from .providers import JSONChatModel
|
|
125
|
+
|
|
126
|
+
request = compiler.prepare(project, args.source, args.name)
|
|
127
|
+
model = JSONChatModel(args.base_url, args.model, os.environ.get(args.api_key_env))
|
|
128
|
+
proposal = asyncio.run(model(request))
|
|
129
|
+
source_hash = request["inventory"]["source_hash"]
|
|
130
|
+
result = compiler.generate(
|
|
131
|
+
project,
|
|
132
|
+
args.source,
|
|
133
|
+
name=args.name,
|
|
134
|
+
profile=args.profile,
|
|
135
|
+
proposal=proposal,
|
|
136
|
+
expected_source_hash=source_hash,
|
|
137
|
+
)
|
|
138
|
+
if args.install:
|
|
139
|
+
result["installation"] = hosts.install(project, name=result["name"])
|
|
140
|
+
return result
|
|
141
|
+
if args.command == "validate":
|
|
142
|
+
bundle = compiler.load_bundle(project, args.name)
|
|
143
|
+
return {"valid": True, "name": args.name, **bundle["generation"]}
|
|
144
|
+
if args.command == "doctor":
|
|
145
|
+
result = hosts.doctor(project)
|
|
146
|
+
if args.mcp:
|
|
147
|
+
try:
|
|
148
|
+
from .mcp_server import smoke_test
|
|
149
|
+
|
|
150
|
+
result["mcp_transport"] = asyncio.run(smoke_test(project))
|
|
151
|
+
result["ok"] = result["ok"] and result["mcp_transport"]["ok"]
|
|
152
|
+
except ImportError as exc:
|
|
153
|
+
raise ValidationError(
|
|
154
|
+
"Install skillstate-kit[mcp] for the MCP transport check"
|
|
155
|
+
) from exc
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
result["mcp_transport"] = {"ok": False, "error": type(exc).__name__}
|
|
158
|
+
result["ok"] = False
|
|
159
|
+
return result
|
|
160
|
+
if args.command == "uninstall":
|
|
161
|
+
return hosts.uninstall(project)
|
|
162
|
+
if args.command == "demo":
|
|
163
|
+
return asyncio.run(run_demo())
|
|
164
|
+
if args.command == "serve":
|
|
165
|
+
try:
|
|
166
|
+
from .mcp_server import serve
|
|
167
|
+
|
|
168
|
+
serve(project)
|
|
169
|
+
except ImportError as exc:
|
|
170
|
+
raise ValidationError("Install skillstate-kit[mcp] to run the MCP server") from exc
|
|
171
|
+
return None
|
|
172
|
+
if args.command == "artifact":
|
|
173
|
+
store = ArtifactStore(project)
|
|
174
|
+
if args.operation == "put":
|
|
175
|
+
path = within(project, args.file)
|
|
176
|
+
if path.stat().st_size > 2_000_000:
|
|
177
|
+
raise ValidationError("Artifact file too large")
|
|
178
|
+
return {"id": store.put(path.read_text(encoding="utf-8"))}
|
|
179
|
+
return store.read(args.id, args.offset, args.length)
|
|
180
|
+
if args.command == "status":
|
|
181
|
+
with service.store() as store:
|
|
182
|
+
return store.list_runs()
|
|
183
|
+
if args.command == "run":
|
|
184
|
+
op = args.operation
|
|
185
|
+
if op == "open":
|
|
186
|
+
return service.open_run(args.name, args.owner, args.id, read(args.observation))
|
|
187
|
+
if op == "context":
|
|
188
|
+
return service.run_context(args.run_id)
|
|
189
|
+
if op == "handoff":
|
|
190
|
+
return service.handoff(args.run_id, args.owner, args.revision, args.to)
|
|
191
|
+
with service.store() as store:
|
|
192
|
+
if op == "update":
|
|
193
|
+
return store.update(
|
|
194
|
+
args.run_id,
|
|
195
|
+
args.owner,
|
|
196
|
+
args.revision,
|
|
197
|
+
read(args.patch),
|
|
198
|
+
read(args.observation),
|
|
199
|
+
done=args.done,
|
|
200
|
+
)
|
|
201
|
+
if op == "events":
|
|
202
|
+
return store.events(args.run_id, args.after)
|
|
203
|
+
if op == "reserve":
|
|
204
|
+
decision = read(args.decision)
|
|
205
|
+
if not isinstance(decision, dict) or set(decision) != {"action", "patch"}:
|
|
206
|
+
raise ValidationError("Native decision must contain exactly action and patch")
|
|
207
|
+
return store.reserve(
|
|
208
|
+
args.run_id, args.owner, args.revision, decision["action"], decision["patch"]
|
|
209
|
+
)
|
|
210
|
+
if op == "unknown":
|
|
211
|
+
return store.mark_unknown(args.operation_id, args.owner, args.reason)
|
|
212
|
+
if op == "result":
|
|
213
|
+
result = read(args.result)
|
|
214
|
+
if not isinstance(result, dict) or set(result) != {"success", "observation"}:
|
|
215
|
+
raise ValidationError("Result must contain exactly success and observation")
|
|
216
|
+
return store.record_result(
|
|
217
|
+
args.operation_id,
|
|
218
|
+
args.owner,
|
|
219
|
+
result["success"],
|
|
220
|
+
result["observation"],
|
|
221
|
+
reconcile=args.reconcile,
|
|
222
|
+
)
|
|
223
|
+
raise ValidationError("Unknown command")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main(argv: list[str] | None = None) -> int:
|
|
227
|
+
def emit(value, *, stream=None):
|
|
228
|
+
# Strict validation first; ASCII JSON escapes also work in legacy Windows
|
|
229
|
+
# pipes without failing after a mutation has already committed.
|
|
230
|
+
print(json.dumps(json.loads(dumps(value)), ensure_ascii=True), file=stream)
|
|
231
|
+
|
|
232
|
+
args = parser().parse_args(argv)
|
|
233
|
+
try:
|
|
234
|
+
result = dispatch(args)
|
|
235
|
+
if result is not None:
|
|
236
|
+
emit(result)
|
|
237
|
+
return 1 if args.command == "doctor" and not result["ok"] else 0
|
|
238
|
+
except SkillStateError as exc:
|
|
239
|
+
emit({"error": exc.code, "message": str(exc)}, stream=sys.stderr)
|
|
240
|
+
return 2
|
|
241
|
+
except (OSError, UnicodeError) as exc:
|
|
242
|
+
emit({"error": "io_error", "message": str(exc)}, stream=sys.stderr)
|
|
243
|
+
return 3
|
|
244
|
+
except sqlite3.Error as exc:
|
|
245
|
+
emit(
|
|
246
|
+
{
|
|
247
|
+
"error": "storage_error",
|
|
248
|
+
"message": f"SQLite operation failed: {type(exc).__name__}",
|
|
249
|
+
},
|
|
250
|
+
stream=sys.stderr,
|
|
251
|
+
)
|
|
252
|
+
return 3
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
raise SystemExit(main())
|