stackshift-agent 0.1.0__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.
@@ -0,0 +1,32 @@
1
+ from .actions import Actions
2
+ from .agent import AgentTool, StackShiftAgent, create_agent
3
+ from .agreements import Agreements
4
+ from .errors import ActionRefusedError, ActionWaitingError, PlatformCallError, StackShiftAgentError
5
+ from .http import Response
6
+ from .inbox import Inbox, address_of
7
+ from .platform import Platform
8
+ from .run import AgentEvent, ApprovalDecided
9
+ from .state import Lock, State
10
+
11
+ agent = create_agent()
12
+
13
+ __all__ = [
14
+ "Actions",
15
+ "ActionRefusedError",
16
+ "ActionWaitingError",
17
+ "AgentEvent",
18
+ "AgentTool",
19
+ "ApprovalDecided",
20
+ "Agreements",
21
+ "Inbox",
22
+ "Lock",
23
+ "Platform",
24
+ "PlatformCallError",
25
+ "Response",
26
+ "StackShiftAgent",
27
+ "StackShiftAgentError",
28
+ "State",
29
+ "address_of",
30
+ "agent",
31
+ "create_agent",
32
+ ]
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import inspect
5
+ import json
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from typing import Any, Awaitable, Callable
9
+ from urllib.parse import quote
10
+
11
+ from .api import Api
12
+ from .errors import ActionRefusedError, ActionWaitingError, StackShiftAgentError
13
+ from .run import current_run
14
+ from .state import State
15
+
16
+ PENDING_SECONDS = 8 * 24 * 3600
17
+ OUTPUT_LIMIT = 60_000
18
+ SUMMARY_LIMIT = 240
19
+ WAIT_SECONDS = 55
20
+
21
+
22
+ @dataclass
23
+ class Entry:
24
+ key: str
25
+ run: Callable[[Any], Any]
26
+
27
+
28
+ class Actions:
29
+ def __init__(self, api: Api, state: State) -> None:
30
+ self._api = api
31
+ self._state = state
32
+ self._entries: dict[str, Entry] = {}
33
+
34
+ def register(self, key: str, run: Callable[[Any], Any], wait: bool = False) -> Callable[[Any], Awaitable[Any]]:
35
+ if key in self._entries:
36
+ raise StackShiftAgentError(f"The action {key} is already registered.", code="action_registered")
37
+ self._entries[key] = Entry(key, run)
38
+
39
+ async def admitted(args: Any = None) -> Any:
40
+ return await self._admit_and_run(key, args, wait)
41
+
42
+ return functools.wraps(run)(admitted)
43
+
44
+ def has(self, key: str) -> bool:
45
+ return key in self._entries
46
+
47
+ async def invoke(self, key: str, args: Any, wait: bool = False) -> Any:
48
+ if key not in self._entries:
49
+ raise StackShiftAgentError(f"There is no action {key} in this agent.", status=404, code="action_unknown")
50
+ return await self._admit_and_run(key, args, wait)
51
+
52
+ async def approved(self, admission_id: str, key: str, args: Any) -> bool:
53
+ parts = key.split(".")
54
+ entry = self._entries.get(".".join(parts[2:]) if parts[0] == "custom" and len(parts) >= 3 else key)
55
+ if entry is None:
56
+ return False
57
+ await self._state.delete(_pending_key(admission_id))
58
+ await self._execute(entry, admission_id, args)
59
+ return True
60
+
61
+ async def forget(self, admission_id: str) -> None:
62
+ await self._state.delete(_pending_key(admission_id))
63
+
64
+ async def _admit_and_run(self, key: str, args: Any, wait: bool) -> Any:
65
+ context = current_run.get()
66
+ if context is None:
67
+ raise StackShiftAgentError("Actions run while the agent handles an event.", code="no_run")
68
+ admission = await self._api.request(
69
+ "POST",
70
+ f"/agents/self/actions/{quote(key, safe='')}/admit",
71
+ {"run_id": context.run_id, "arguments": args if args is not None else {}, "correlation_id": str(uuid.uuid4())},
72
+ )
73
+ if admission["decision"] == "waiting" and wait:
74
+ admission = await self._api.request("GET", f"/agents/self/admissions/{admission['admission_id']}/wait", query={"seconds": WAIT_SECONDS}) or admission
75
+ if admission["decision"] == "refused":
76
+ raise ActionRefusedError(key, admission.get("reason", ""), admission.get("message", ""), admission.get("untrusted"))
77
+ if admission["decision"] == "waiting":
78
+ await self._state.set(
79
+ _pending_key(admission["admission_id"]),
80
+ {"key": key, "arguments": args, "admission_id": admission["admission_id"]},
81
+ ttl_seconds=PENDING_SECONDS,
82
+ )
83
+ context.waiting = True
84
+ raise ActionWaitingError(key, admission["admission_id"], admission.get("approval_id"), admission.get("message"))
85
+ return await self._execute(self._entries[key], admission["admission_id"], args)
86
+
87
+ async def _execute(self, entry: Entry, admission_id: str, args: Any) -> Any:
88
+ try:
89
+ result = entry.run(args)
90
+ if inspect.isawaitable(result):
91
+ result = await result
92
+ except Exception as failure:
93
+ await self._report(admission_id, "failed", str(failure), "")
94
+ raise
95
+ output = result if isinstance(result, str) else json.dumps(result)
96
+ await self._report(admission_id, "succeeded", output[:SUMMARY_LIMIT], output[:OUTPUT_LIMIT])
97
+ return result
98
+
99
+ async def _report(self, admission_id: str, status: str, summary: str, output: str) -> None:
100
+ await self._api.request("POST", f"/agents/self/admissions/{admission_id}/result", {"status": status, "summary": summary[:SUMMARY_LIMIT], "output": output})
101
+
102
+
103
+ def _pending_key(admission_id: str) -> str:
104
+ return f"pending:{admission_id}"
File without changes
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..agent import StackShiftAgent
6
+ from ..models import load
7
+
8
+
9
+ async def claude_tools(agent: StackShiftAgent, *, server_name: str = "stackshift", sdk: Any = None) -> dict[str, Any]:
10
+ module = sdk or load("claude_agent_sdk", "Install claude-agent-sdk to use the Claude adapter.")
11
+ tools = await agent.tools()
12
+ definitions = []
13
+ for definition in tools:
14
+ definitions.append(_define(module, definition))
15
+ return {
16
+ "mcp_servers": {server_name: module.create_sdk_mcp_server(name=server_name, tools=definitions)},
17
+ "allowed_tools": [f"mcp__{server_name}__{definition.name}" for definition in tools],
18
+ }
19
+
20
+
21
+ def _define(module: Any, definition: Any) -> Any:
22
+ async def handler(arguments: dict[str, Any]) -> dict[str, Any]:
23
+ return {"content": [{"type": "text", "text": await definition.execute(arguments)}]}
24
+
25
+ annotations = module.ToolAnnotations(readOnlyHint=definition.read_only)
26
+ return module.tool(definition.name, definition.description, definition.parameters, annotations=annotations)(handler)
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from ..agent import StackShiftAgent
7
+ from ..models import load
8
+
9
+
10
+ async def openai_tools(agent: StackShiftAgent, *, sdk: Any = None) -> list[Any]:
11
+ module = sdk or load("agents", "Install openai-agents to use the OpenAI Agents adapter.")
12
+ return [_define(module, definition) for definition in await agent.tools()]
13
+
14
+
15
+ def _define(module: Any, definition: Any) -> Any:
16
+ async def invoke(_: Any, arguments: str) -> str:
17
+ return await definition.execute(json.loads(arguments) if arguments else {})
18
+
19
+ return module.FunctionTool(
20
+ name=definition.name,
21
+ description=definition.description,
22
+ params_json_schema=definition.parameters,
23
+ on_invoke_tool=invoke,
24
+ strict_json_schema=False,
25
+ )
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import threading
7
+ from dataclasses import dataclass
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from typing import Any, Awaitable, Callable, Mapping
10
+
11
+ from .actions import Actions
12
+ from .agreements import Agreements
13
+ from .api import Api
14
+ from .config import Config, resolve_config
15
+ from .egress import fetch as egress_fetch
16
+ from .errors import ActionWaitingError, StackShiftAgentError
17
+ from .http import Response
18
+ from .inbox import Inbox
19
+ from .models import anthropic_client, openai_client
20
+ from .platform import Platform
21
+ from .run import AgentEvent, ApprovalDecided, RunContext, current_run
22
+ from .state import State
23
+ from .tokens import TokenStore
24
+ from .verify import verify_delivery
25
+
26
+ EVENT_PATH = "/stackshift/events"
27
+ BODY_LIMIT = 4 << 20
28
+ SEEN_LIMIT = 5000
29
+ SEEN_SECONDS = 8 * 24 * 3600
30
+
31
+ Handler = Callable[[AgentEvent], Any]
32
+
33
+
34
+ @dataclass
35
+ class AgentTool:
36
+ name: str
37
+ description: str
38
+ parameters: dict[str, Any]
39
+ read_only: bool
40
+ execute: Callable[[dict[str, Any]], Awaitable[str]]
41
+
42
+
43
+ class StackShiftAgent:
44
+ def __init__(self, **options: Any) -> None:
45
+ self.config: Config = resolve_config(**options)
46
+ self._tokens = TokenStore(self.config.token_file)
47
+ self._api = Api(self.config, self._tokens)
48
+ self.state = State(self._api)
49
+ self.platform = Platform(self.config, self._tokens)
50
+ self._actions = Actions(self._api, self.state)
51
+ self.inbox = Inbox(self.platform, self.self_)
52
+ self.agreements = Agreements(self.platform, self.inbox.id)
53
+ self._handlers: dict[str, list[Handler]] = {}
54
+ self._seen: dict[str, None] = {}
55
+ self._running: set[str] = set()
56
+ self._guard = threading.Lock()
57
+ self._self: dict[str, Any] | None = None
58
+
59
+ def on(self, event: str, handler: Handler | None = None) -> Any:
60
+ def register(function: Handler) -> Handler:
61
+ self._handlers.setdefault(event, []).append(function)
62
+ return function
63
+
64
+ return register if handler is None else register(handler)
65
+
66
+ def action(self, key: str, run: Callable[[Any], Any] | None = None, *, wait: bool = False) -> Any:
67
+ if run is not None:
68
+ return self._actions.register(key, run, wait)
69
+ return lambda function: self._actions.register(key, function, wait)
70
+
71
+ async def self_(self) -> dict[str, Any]:
72
+ if self._self is None:
73
+ self._self = await self._api.request("GET", "/agents/self")
74
+ return self._self
75
+
76
+ async def tools(self) -> list[AgentTool]:
77
+ declared = (await self.self_())["actions"]
78
+ return [
79
+ AgentTool(
80
+ name=item["key"],
81
+ description=item["summary"],
82
+ parameters=item["input_schema"],
83
+ read_only=item["tier"] == "read",
84
+ execute=self._tool_runner(item["key"]),
85
+ )
86
+ for item in declared
87
+ if self._actions.has(item["key"])
88
+ ]
89
+
90
+ async def untrusted(self, text: str, source: str) -> str:
91
+ context = current_run.get()
92
+ if context is None:
93
+ raise StackShiftAgentError("Mark text as untrusted while the agent handles an event.", code="no_run")
94
+ await self._api.request("POST", "/agents/self/untrusted", {"run_id": context.run_id, "source": source, "text": text})
95
+ return text
96
+
97
+ async def fetch(self, url: str, *, method: str = "GET", headers: Mapping[str, str] | None = None, body: bytes | str | None = None) -> Response:
98
+ return await egress_fetch(self.config, url, method=method, headers=headers, body=body)
99
+
100
+ def anthropic(self) -> Any:
101
+ return anthropic_client(self.config, self._tokens, asynchronous=False)
102
+
103
+ def async_anthropic(self) -> Any:
104
+ return anthropic_client(self.config, self._tokens, asynchronous=True)
105
+
106
+ def openai(self) -> Any:
107
+ return openai_client(self.config, self._tokens, asynchronous=False)
108
+
109
+ def async_openai(self) -> Any:
110
+ return openai_client(self.config, self._tokens, asynchronous=True)
111
+
112
+ def serve(self) -> ThreadingHTTPServer:
113
+ if not self.config.event_key:
114
+ raise StackShiftAgentError("STACKSHIFT_EVENT_KEY is not set. StackShift sets it when the agent runs.", code="event_key_missing")
115
+ agent = self
116
+
117
+ class Events(BaseHTTPRequestHandler):
118
+ def do_GET(self) -> None:
119
+ self._send(200, {"ok": True}) if self.path == "/healthz" else self._send(404, {"error": "Not found."})
120
+
121
+ def do_POST(self) -> None:
122
+ if self.path != EVENT_PATH:
123
+ return self._send(404, {"error": "Not found."})
124
+ length = int(self.headers.get("Content-Length") or 0)
125
+ if length > BODY_LIMIT:
126
+ return self._send(413, {"error": "The event is too large."})
127
+ headers = {name.lower(): value for name, value in self.headers.items()}
128
+ try:
129
+ status, body = asyncio.run(agent.deliver(headers, self.rfile.read(length)))
130
+ except Exception as failure:
131
+ status, body = 500, {"error": str(failure)}
132
+ self._send(status, body)
133
+
134
+ def _send(self, status: int, body: dict[str, Any]) -> None:
135
+ payload = json.dumps(body).encode()
136
+ self.send_response(status)
137
+ self.send_header("Content-Type", "application/json")
138
+ self.send_header("Content-Length", str(len(payload)))
139
+ self.end_headers()
140
+ self.wfile.write(payload)
141
+
142
+ def log_message(self, *_: Any) -> None:
143
+ return
144
+
145
+ return ThreadingHTTPServer(("", self.config.port), Events)
146
+
147
+ def start(self) -> None:
148
+ self.serve().serve_forever()
149
+
150
+ async def deliver(self, headers: Mapping[str, str], body: bytes) -> tuple[int, dict[str, Any]]:
151
+ if not verify_delivery(self.config.event_key, headers, body, self.config.now()):
152
+ return 401, {"error": "The event was not signed by StackShift."}
153
+ token = headers.get("x-stackshift-agent-token")
154
+ run_id = headers.get("x-stackshift-run")
155
+ event = _parse_event(body, run_id or "")
156
+ if not token or not run_id or event is None:
157
+ return 400, {"error": "The event is missing its token, run or body."}
158
+ with self._guard:
159
+ if event.id in self._seen:
160
+ return 200, {"outcome": "done"}
161
+ if event.id in self._running:
162
+ return 503, {"error": "This event is already running."}
163
+ self._running.add(event.id)
164
+ try:
165
+ await asyncio.to_thread(self._tokens.set, token)
166
+ return 200, await self._run(event)
167
+ finally:
168
+ with self._guard:
169
+ self._running.discard(event.id)
170
+
171
+ async def _run(self, event: AgentEvent) -> dict[str, Any]:
172
+ if await self._stored(event.id):
173
+ return {"outcome": "done"}
174
+ context = RunContext(run_id=event.run_id, event=event)
175
+ reset = current_run.set(context)
176
+ error: str | None = None
177
+ try:
178
+ await self._dispatch(event)
179
+ outcome = "waiting" if context.waiting else "done"
180
+ except ActionWaitingError:
181
+ outcome = "waiting"
182
+ except Exception as failure:
183
+ outcome, error = "failed", str(failure)
184
+ finally:
185
+ current_run.reset(reset)
186
+ if outcome != "failed":
187
+ await self._remember(event.id)
188
+ return {"outcome": outcome} if error is None else {"outcome": outcome, "error": error}
189
+
190
+ async def _dispatch(self, event: AgentEvent) -> None:
191
+ if event.type == "approval.decided":
192
+ data: ApprovalDecided = event.data
193
+ if data["decision"] == "approved":
194
+ await self._actions.approved(data["admission_id"], data["action_key"], data["arguments"])
195
+ else:
196
+ await self._actions.forget(data["admission_id"])
197
+ for handler in self._handlers.get(event.type, []):
198
+ result = handler(event)
199
+ if inspect.isawaitable(result):
200
+ await result
201
+
202
+ async def _stored(self, event_id: str) -> bool:
203
+ try:
204
+ return await self.state.get(f"event:{event_id}") is not None
205
+ except StackShiftAgentError:
206
+ return False
207
+
208
+ async def _remember(self, event_id: str) -> None:
209
+ with self._guard:
210
+ self._seen[event_id] = None
211
+ if len(self._seen) > SEEN_LIMIT:
212
+ del self._seen[next(iter(self._seen))]
213
+ try:
214
+ await self.state.set(f"event:{event_id}", True, ttl_seconds=SEEN_SECONDS)
215
+ except StackShiftAgentError:
216
+ return
217
+
218
+ def _tool_runner(self, key: str) -> Callable[[dict[str, Any]], Awaitable[str]]:
219
+ async def run(arguments: dict[str, Any]) -> str:
220
+ try:
221
+ result = await self._actions.invoke(key, arguments)
222
+ except ActionWaitingError:
223
+ return f"{key} needs the owner's approval. It is saved and runs once the owner approves. Stop working on this for now."
224
+ except StackShiftAgentError as failure:
225
+ return f"{key} was not done: {failure}"
226
+ return result if isinstance(result, str) else json.dumps(result)
227
+
228
+ return run
229
+
230
+
231
+ def _parse_event(body: bytes, run_id: str) -> AgentEvent | None:
232
+ try:
233
+ parsed = json.loads(body)
234
+ except ValueError:
235
+ return None
236
+ if not isinstance(parsed, dict) or not parsed.get("event") or not parsed.get("id"):
237
+ return None
238
+ return AgentEvent(
239
+ type=parsed["event"],
240
+ id=parsed["id"],
241
+ agent=parsed.get("agent", ""),
242
+ at=parsed.get("at", ""),
243
+ data=parsed.get("data"),
244
+ untrusted=parsed.get("untrusted") or [],
245
+ run_id=run_id,
246
+ )
247
+
248
+
249
+ def create_agent(**options: Any) -> StackShiftAgent:
250
+ return StackShiftAgent(**options)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Awaitable, Callable
4
+
5
+ from .platform import Platform
6
+
7
+
8
+ class Agreements:
9
+ def __init__(self, platform: Platform, inbox_id: Callable[[], Awaitable[str]]) -> None:
10
+ self._platform = platform
11
+ self._inbox_id = inbox_id
12
+
13
+ async def list(self, **filters: Any) -> Any:
14
+ return await self._platform.inspect("agreements.list", filters)
15
+
16
+ async def inspect(self, thread_id: str) -> Any:
17
+ return await self._platform.inspect("agreements.inspect", {"thread_id": thread_id})
18
+
19
+ async def limits(self, inbox_id: str) -> Any:
20
+ return await self._platform.inspect("agreements.limits.inspect", {"inbox_id": inbox_id})
21
+
22
+ async def verify(self, record: dict[str, Any]) -> Any:
23
+ return await self._platform.inspect("agreements.verify", record)
24
+
25
+ async def wait(self, thread_id: str, after: str | None = None) -> Any:
26
+ return await self._platform.inspect("agreements.wait", {key: value for key, value in {"thread_id": thread_id, "after": after}.items() if value is not None})
27
+
28
+ async def propose(self, offer: dict[str, Any], **options: Any) -> dict[str, Any]:
29
+ return await self._platform.call("agreements.propose", {"inbox_id": await self._inbox_id(), **offer}, **options)
30
+
31
+ async def respond(self, thread_id: str, answer: dict[str, Any], **options: Any) -> dict[str, Any]:
32
+ return await self._platform.call("agreements.respond", {"thread_id": thread_id, **answer}, **options)
33
+
34
+ async def cancel(self, thread_id: str, details: dict[str, Any] | None = None, **options: Any) -> dict[str, Any]:
35
+ return await self._platform.call("agreements.cancel", {"thread_id": thread_id, **(details or {})}, **options)
36
+
37
+ async def count_reply(self, thread_id: str, offer_id: str, reply_message_id: str, **options: Any) -> dict[str, Any]:
38
+ return await self._platform.call("agreements.reply.count", {"thread_id": thread_id, "offer_id": offer_id, "reply_message_id": reply_message_id}, **options)
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+ from urllib.parse import urlencode
6
+
7
+ from .config import Config
8
+ from .errors import StackShiftAgentError
9
+ from .http import send
10
+ from .tokens import TokenStore
11
+
12
+ USER_AGENT = "stackshift-agent-python/0.1"
13
+
14
+
15
+ class Api:
16
+ def __init__(self, config: Config, tokens: TokenStore) -> None:
17
+ self.config = config
18
+ self.tokens = tokens
19
+
20
+ async def request(self, method: str, path: str, body: Any = None, *, query: dict[str, Any] | None = None, allow_not_found: bool = False) -> Any:
21
+ params = {key: value for key, value in (query or {}).items() if value is not None}
22
+ suffix = f"?{urlencode(params)}" if params else ""
23
+ response = await send(
24
+ method,
25
+ f"{self.config.origin}/api/v1{path}{suffix}",
26
+ {
27
+ "Authorization": f"Bearer {self.tokens.current()}",
28
+ "Content-Type": "application/json",
29
+ "User-Agent": USER_AGENT,
30
+ },
31
+ None if body is None else json.dumps(body).encode(),
32
+ )
33
+ if response.status == 404 and allow_not_found:
34
+ return None
35
+ parsed = _parse(response.body)
36
+ if not response.ok:
37
+ failure = parsed.get("error") if isinstance(parsed, dict) else None
38
+ failure = failure if isinstance(failure, dict) else {}
39
+ raise StackShiftAgentError(
40
+ failure.get("message") or f"StackShift answered {response.status}.",
41
+ status=response.status,
42
+ code=failure.get("code"),
43
+ )
44
+ return parsed
45
+
46
+
47
+ def _parse(body: bytes) -> Any:
48
+ if not body:
49
+ return None
50
+ try:
51
+ return json.loads(body)
52
+ except ValueError:
53
+ return None
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Callable, Mapping
9
+
10
+ DEFAULT_ORIGIN = "https://api.stackshift.cloud"
11
+
12
+
13
+ @dataclass
14
+ class Config:
15
+ origin: str
16
+ event_key: str
17
+ port: int
18
+ token_file: Path
19
+ anthropic_base_url: str
20
+ openai_base_url: str
21
+ secret_proxy_url: str
22
+ env: Mapping[str, str]
23
+ now: Callable[[], float] = field(default=time.time)
24
+
25
+
26
+ def resolve_config(
27
+ *,
28
+ api_url: str | None = None,
29
+ event_key: str | None = None,
30
+ port: int | None = None,
31
+ token_file: str | os.PathLike[str] | None = None,
32
+ env: Mapping[str, str] | None = None,
33
+ now: Callable[[], float] | None = None,
34
+ ) -> Config:
35
+ values = os.environ if env is None else env
36
+ origin = re.sub(r"/api/v1$", "", (api_url or values.get("STACKSHIFT_API_URL") or DEFAULT_ORIGIN).rstrip("/"))
37
+ return Config(
38
+ origin=origin,
39
+ event_key=event_key or values.get("STACKSHIFT_EVENT_KEY", ""),
40
+ port=port if port is not None else int(values.get("PORT", "8080")),
41
+ token_file=Path(token_file) if token_file else Path.home() / ".stackshift" / "agent-token",
42
+ anthropic_base_url=values.get("ANTHROPIC_BASE_URL", f"{origin}/ai"),
43
+ openai_base_url=values.get("OPENAI_BASE_URL", f"{origin}/ai/v1"),
44
+ secret_proxy_url=values.get("STACKSHIFT_SECRET_PROXY_URL", "").rstrip("/"),
45
+ env=values,
46
+ now=now or time.time,
47
+ )
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Mapping
5
+ from urllib.parse import urlsplit
6
+
7
+ from .config import Config
8
+ from .http import Response, send
9
+
10
+ PLACEHOLDER = re.compile(r"^stackshift-secret://([0-9a-f-]{36})/")
11
+
12
+
13
+ def egress_key_env(host: str) -> str:
14
+ return "STACKSHIFT_EGRESS_KEY_" + re.sub(r"[^A-Za-z0-9]", "_", host).upper()
15
+
16
+
17
+ async def fetch(config: Config, url: str, *, method: str = "GET", headers: Mapping[str, str] | None = None, body: bytes | str | None = None) -> Response:
18
+ payload = body.encode() if isinstance(body, str) else body
19
+ parts = urlsplit(url)
20
+ host = (parts.hostname or "").lower()
21
+ placeholder = config.env.get(egress_key_env(host), "")
22
+ match = PLACEHOLDER.match(placeholder)
23
+ if not match or not config.secret_proxy_url or parts.scheme != "https" or parts.port is not None:
24
+ return await send(method, url, headers, payload)
25
+ path = parts.path + (f"?{parts.query}" if parts.query else "")
26
+ return await send(
27
+ method,
28
+ f"{config.secret_proxy_url}/{match.group(1)}/{host}{path}",
29
+ {**(headers or {}), "X-Stackshift-Secret": placeholder},
30
+ payload,
31
+ follow_redirects=False,
32
+ )
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class StackShiftAgentError(RuntimeError):
7
+ def __init__(self, message: str, *, status: int | None = None, code: str | None = None) -> None:
8
+ super().__init__(message)
9
+ self.status = status
10
+ self.code = code
11
+
12
+
13
+ class ActionRefusedError(StackShiftAgentError):
14
+ def __init__(self, action: str, reason: str, message: str, untrusted: dict[str, Any] | None = None) -> None:
15
+ super().__init__(message or f"StackShift refused {action}.", status=403, code=reason or "refused")
16
+ self.action = action
17
+ self.reason = reason
18
+ self.untrusted = untrusted
19
+
20
+
21
+ class ActionWaitingError(StackShiftAgentError):
22
+ def __init__(self, action: str, admission_id: str, approval_id: str | None = None, message: str | None = None) -> None:
23
+ super().__init__(message or f"{action} is waiting for the owner's approval.", status=202, code="waiting")
24
+ self.action = action
25
+ self.admission_id = admission_id
26
+ self.approval_id = approval_id
27
+
28
+
29
+ class PlatformCallError(StackShiftAgentError):
30
+ def __init__(self, key: str, message: str, details: Any = None) -> None:
31
+ super().__init__(message, code="platform_call_failed")
32
+ self.key = key
33
+ self.details = details
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import urllib.error
6
+ import urllib.request
7
+ from dataclasses import dataclass
8
+ from typing import Any, Mapping
9
+
10
+
11
+ @dataclass
12
+ class Response:
13
+ status: int
14
+ headers: Mapping[str, str]
15
+ body: bytes
16
+
17
+ @property
18
+ def ok(self) -> bool:
19
+ return 200 <= self.status < 300
20
+
21
+ def text(self) -> str:
22
+ return self.body.decode("utf-8", errors="replace")
23
+
24
+ def json(self) -> Any:
25
+ return json.loads(self.body) if self.body else None
26
+
27
+
28
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
29
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
30
+ return None
31
+
32
+
33
+ def send_sync(method: str, url: str, headers: Mapping[str, str] | None = None, body: bytes | None = None, follow_redirects: bool = True, timeout: float = 120) -> Response:
34
+ request = urllib.request.Request(url, data=body, method=method, headers=dict(headers or {}))
35
+ opener = urllib.request.build_opener() if follow_redirects else urllib.request.build_opener(_NoRedirect())
36
+ try:
37
+ with opener.open(request, timeout=timeout) as reply:
38
+ return Response(reply.status, dict(reply.headers.items()), reply.read())
39
+ except urllib.error.HTTPError as failure:
40
+ return Response(failure.code, dict(failure.headers.items()), failure.read())
41
+
42
+
43
+ async def send(method: str, url: str, headers: Mapping[str, str] | None = None, body: bytes | None = None, follow_redirects: bool = True, timeout: float = 120) -> Response:
44
+ return await asyncio.to_thread(send_sync, method, url, headers, body, follow_redirects, timeout)
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Awaitable, Callable
5
+
6
+ from .errors import StackShiftAgentError
7
+ from .platform import Platform
8
+
9
+
10
+ class Inbox:
11
+ def __init__(self, platform: Platform, self_: Callable[[], Awaitable[dict[str, Any]]]) -> None:
12
+ self._platform = platform
13
+ self._self = self_
14
+
15
+ async def id(self) -> str:
16
+ inbox = (await self._self())["agent"].get("inbox_mailbox_id")
17
+ if not inbox:
18
+ raise StackShiftAgentError("This agent has no inbox. Add an inbox to the agent block in stackshift.yaml.", status=409, code="no_inbox")
19
+ return inbox
20
+
21
+ async def info(self) -> Any:
22
+ return await self._platform.inspect("mail.agent_inboxes.get", {"inbox_id": await self.id()})
23
+
24
+ async def send(
25
+ self,
26
+ *,
27
+ to: list[str],
28
+ subject: str,
29
+ text: str | None = None,
30
+ html: str | None = None,
31
+ cc: list[str] | None = None,
32
+ reply_to_message_id: str | None = None,
33
+ **options: Any,
34
+ ) -> dict[str, Any]:
35
+ configuration = {
36
+ "inbox_id": await self.id(),
37
+ "to": to,
38
+ "cc": cc,
39
+ "subject": subject,
40
+ "text": text,
41
+ "html": html,
42
+ "reply_to_message_id": reply_to_message_id,
43
+ }
44
+ return await self._platform.call("mail.agent_inbox.send", {key: value for key, value in configuration.items() if value is not None}, **options)
45
+
46
+ async def reply(self, message: dict[str, Any], text: str, **options: Any) -> dict[str, Any]:
47
+ subject = message["subject"]
48
+ return await self.send(
49
+ to=[address_of(message["from"])],
50
+ subject=subject if re.match(r"re:", subject, re.IGNORECASE) else f"Re: {subject}",
51
+ text=text,
52
+ reply_to_message_id=message["id"],
53
+ **options,
54
+ )
55
+
56
+
57
+ def address_of(sender: str) -> str:
58
+ match = re.search(r"<([^>]+)>\s*$", sender)
59
+ return (match.group(1) if match else sender).strip()
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ from typing import Any
5
+
6
+ from .config import Config
7
+ from .errors import StackShiftAgentError
8
+ from .tokens import TokenStore
9
+
10
+ PLACEHOLDER_KEY = "stackshift-agent-token"
11
+
12
+
13
+ def load(name: str, message: str) -> Any:
14
+ try:
15
+ return importlib.import_module(name)
16
+ except ImportError:
17
+ raise StackShiftAgentError(message, code="package_missing") from None
18
+
19
+
20
+ def _stamp(tokens: TokenStore):
21
+ def apply(request: Any) -> None:
22
+ token = tokens.current()
23
+ request.headers["x-api-key"] = token
24
+ request.headers["Authorization"] = f"Bearer {token}"
25
+
26
+ return apply
27
+
28
+
29
+ def _async_stamp(tokens: TokenStore):
30
+ stamp = _stamp(tokens)
31
+
32
+ async def apply(request: Any) -> None:
33
+ stamp(request)
34
+
35
+ return apply
36
+
37
+
38
+ def anthropic_client(config: Config, tokens: TokenStore, *, asynchronous: bool) -> Any:
39
+ sdk = load("anthropic", "Install anthropic to use agent.anthropic().")
40
+ httpx = load("httpx", "Install httpx to use agent.anthropic().")
41
+ if asynchronous:
42
+ return sdk.AsyncAnthropic(api_key=PLACEHOLDER_KEY, base_url=config.anthropic_base_url, http_client=httpx.AsyncClient(event_hooks={"request": [_async_stamp(tokens)]}))
43
+ return sdk.Anthropic(api_key=PLACEHOLDER_KEY, base_url=config.anthropic_base_url, http_client=httpx.Client(event_hooks={"request": [_stamp(tokens)]}))
44
+
45
+
46
+ def openai_client(config: Config, tokens: TokenStore, *, asynchronous: bool) -> Any:
47
+ sdk = load("openai", "Install openai to use agent.openai().")
48
+ httpx = load("httpx", "Install httpx to use agent.openai().")
49
+ if asynchronous:
50
+ return sdk.AsyncOpenAI(api_key=PLACEHOLDER_KEY, base_url=config.openai_base_url, http_client=httpx.AsyncClient(event_hooks={"request": [_async_stamp(tokens)]}))
51
+ return sdk.OpenAI(api_key=PLACEHOLDER_KEY, base_url=config.openai_base_url, http_client=httpx.Client(event_hooks={"request": [_stamp(tokens)]}))
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from typing import Any
6
+
7
+ from .config import Config
8
+ from .errors import PlatformCallError, StackShiftAgentError
9
+ from .http import send
10
+ from .tokens import TokenStore
11
+
12
+ UNSETTLED = {"waiting_approval", "queued", "running", "pending"}
13
+ DEFAULT_WAIT_SECONDS = 55
14
+ LONGEST_WAIT_SECONDS = 55
15
+
16
+
17
+ class Platform:
18
+ def __init__(self, config: Config, tokens: TokenStore) -> None:
19
+ self._config = config
20
+ self._tokens = tokens
21
+ self._counter = 0
22
+
23
+ async def call(
24
+ self,
25
+ key: str,
26
+ configuration: dict[str, Any] | None = None,
27
+ *,
28
+ resource_id: str | None = None,
29
+ reason: str | None = None,
30
+ expected_resource_version: str | None = None,
31
+ idempotency_key: str | None = None,
32
+ wait: bool | int = True,
33
+ ) -> dict[str, Any]:
34
+ request = _strip({
35
+ "action": key,
36
+ "configuration": configuration or {},
37
+ "resource_id": resource_id,
38
+ "reason": reason,
39
+ "expected_resource_version": expected_resource_version,
40
+ })
41
+ proposed = await self._tool("operation_propose", {"request": request, "idempotency_key": idempotency_key or str(uuid.uuid4())}, key)
42
+ operation = _unwrap(proposed)
43
+ budget = 0 if wait is False else DEFAULT_WAIT_SECONDS if wait is True else int(wait)
44
+ deadline = self._config.now() + budget
45
+ while operation.get("state") in UNSETTLED and self._config.now() < deadline:
46
+ seconds = max(1, min(LONGEST_WAIT_SECONDS, int(deadline - self._config.now() + 0.999)))
47
+ waited = _unwrap(await self._tool("operation_wait", {"operationID": operation["id"], "timeout_seconds": seconds}, key))
48
+ operation = waited["operation"]
49
+ if waited.get("settled"):
50
+ break
51
+ return operation
52
+
53
+ async def inspect(self, key: str, configuration: dict[str, Any] | None = None, *, resource_id: str | None = None) -> Any:
54
+ answer = await self._tool("inspect", _strip({"action": key, "configuration": configuration or {}, "resource_id": resource_id}), key)
55
+ return _unwrap(answer)
56
+
57
+ async def _tool(self, name: str, arguments: dict[str, Any], key: str) -> Any:
58
+ is_error, text = await self._rpc(name, arguments)
59
+ value = _parse(text)
60
+ if is_error:
61
+ failure = value if isinstance(value, dict) else {}
62
+ nested = failure.get("error") if isinstance(failure.get("error"), dict) else {}
63
+ raise PlatformCallError(key, failure.get("message") or nested.get("message") or text or f"{key} was refused.", value)
64
+ return value
65
+
66
+ async def _rpc(self, name: str, arguments: dict[str, Any]) -> tuple[bool, str]:
67
+ self._counter += 1
68
+ response = await send(
69
+ "POST",
70
+ f"{self._config.origin}/api/v1/agents/mcp",
71
+ {
72
+ "Authorization": f"Bearer {self._tokens.current()}",
73
+ "Content-Type": "application/json",
74
+ "Accept": "application/json, text/event-stream",
75
+ "User-Agent": "stackshift-agent-python/0.1",
76
+ },
77
+ json.dumps({"jsonrpc": "2.0", "id": self._counter, "method": "tools/call", "params": {"name": name, "arguments": arguments}}).encode(),
78
+ )
79
+ raw = response.text()
80
+ if not response.ok:
81
+ failure = _parse(raw)
82
+ error = failure.get("error", {}) if isinstance(failure, dict) and isinstance(failure.get("error"), dict) else {}
83
+ raise StackShiftAgentError(error.get("message") or f"StackShift answered {response.status}.", status=response.status, code=error.get("code"))
84
+ envelope = _envelope(raw, response.headers.get("Content-Type", ""))
85
+ if envelope.get("error"):
86
+ raise StackShiftAgentError(envelope["error"].get("message") or "The call failed.", status=response.status, code=str(envelope["error"].get("code", "")))
87
+ result = envelope.get("result") or {}
88
+ text = "".join(part.get("text", "") for part in result.get("content", []) if part.get("type") == "text")
89
+ return result.get("isError") is True, text
90
+
91
+
92
+ def _envelope(raw: str, content_type: str) -> dict[str, Any]:
93
+ if "text/event-stream" in content_type:
94
+ data = [line[5:].strip() for line in raw.split("\n") if line.startswith("data:") and line[5:].strip()]
95
+ parsed = _parse(data[-1]) if data else None
96
+ else:
97
+ parsed = _parse(raw)
98
+ return parsed if isinstance(parsed, dict) else {}
99
+
100
+
101
+ def _parse(text: str) -> Any:
102
+ try:
103
+ return json.loads(text)
104
+ except ValueError:
105
+ return text
106
+
107
+
108
+ def _unwrap(value: Any) -> Any:
109
+ if isinstance(value, dict) and "success" in value and "data" in value:
110
+ return value["data"]
111
+ return value
112
+
113
+
114
+ def _strip(values: dict[str, Any]) -> dict[str, Any]:
115
+ return {key: value for key, value in values.items() if value is not None}
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import contextvars
4
+ from dataclasses import dataclass
5
+ from typing import Any, Literal, TypedDict
6
+
7
+
8
+ class _Decision(TypedDict):
9
+ admission_id: str
10
+ approval_id: str
11
+ action_key: str
12
+ decision: Literal["approved", "denied", "expired"]
13
+ arguments: Any
14
+
15
+
16
+ class ApprovalDecided(_Decision, total=False):
17
+ note: str | None
18
+
19
+
20
+ @dataclass
21
+ class AgentEvent:
22
+ type: str
23
+ id: str
24
+ agent: str
25
+ at: str
26
+ data: Any
27
+ untrusted: list[str]
28
+ run_id: str
29
+
30
+
31
+ @dataclass
32
+ class RunContext:
33
+ run_id: str
34
+ event: AgentEvent
35
+ waiting: bool = False
36
+
37
+
38
+ current_run: contextvars.ContextVar[RunContext | None] = contextvars.ContextVar("stackshift_agent_run", default=None)
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from contextlib import asynccontextmanager
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any, AsyncIterator
7
+ from urllib.parse import quote
8
+
9
+ from .api import Api
10
+ from .errors import StackShiftAgentError
11
+
12
+
13
+ class Lock:
14
+ def __init__(self, api: Api, key: str, owner: str) -> None:
15
+ self._api = api
16
+ self.key = key
17
+ self.owner = owner
18
+
19
+ async def release(self) -> None:
20
+ await self._api.request("DELETE", f"/agents/self/locks/{quote(self.key, safe='')}", {"owner": self.owner})
21
+
22
+
23
+ class State:
24
+ def __init__(self, api: Api) -> None:
25
+ self._api = api
26
+
27
+ async def get(self, key: str) -> Any:
28
+ entry = await self._api.request("GET", f"/agents/self/state/{quote(key, safe='')}", allow_not_found=True)
29
+ return entry["value"] if entry else None
30
+
31
+ async def set(self, key: str, value: Any, *, ttl_seconds: int | None = None) -> None:
32
+ expires_at = None
33
+ if ttl_seconds is not None:
34
+ expires_at = (datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).isoformat().replace("+00:00", "Z")
35
+ await self._api.request("PUT", f"/agents/self/state/{quote(key, safe='')}", {"value": value, "expires_at": expires_at})
36
+
37
+ async def delete(self, key: str) -> None:
38
+ await self._api.request("DELETE", f"/agents/self/state/{quote(key, safe='')}")
39
+
40
+ async def lock(self, key: str, *, ttl_seconds: int | None = None, owner: str | None = None) -> Lock | None:
41
+ holder = owner or str(uuid.uuid4())
42
+ answer = await self._api.request("POST", f"/agents/self/locks/{quote(key, safe='')}", {"owner": holder, "ttl_seconds": ttl_seconds})
43
+ if not answer or not answer.get("acquired"):
44
+ return None
45
+ return Lock(self._api, key, holder)
46
+
47
+ @asynccontextmanager
48
+ async def locked(self, key: str, *, ttl_seconds: int | None = None, owner: str | None = None) -> AsyncIterator[Lock]:
49
+ lock = await self.lock(key, ttl_seconds=ttl_seconds, owner=owner)
50
+ if lock is None:
51
+ raise StackShiftAgentError(f"Another run holds the lock {key}.", status=409, code="lock_held")
52
+ try:
53
+ yield lock
54
+ finally:
55
+ await lock.release()
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import threading
5
+ from pathlib import Path
6
+
7
+ from .errors import StackShiftAgentError
8
+
9
+
10
+ class TokenStore:
11
+ def __init__(self, file: Path) -> None:
12
+ self._file = file
13
+ self._value = ""
14
+ self._lock = threading.Lock()
15
+
16
+ def set(self, token: str) -> None:
17
+ with self._lock:
18
+ self._value = token
19
+ try:
20
+ self._file.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
21
+ descriptor = os.open(self._file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
22
+ with os.fdopen(descriptor, "w") as handle:
23
+ handle.write(token)
24
+ os.chmod(self._file, 0o600)
25
+ except OSError:
26
+ return
27
+
28
+ def current(self) -> str:
29
+ with self._lock:
30
+ if self._value:
31
+ return self._value
32
+ try:
33
+ value = self._file.read_text().strip()
34
+ except OSError:
35
+ value = ""
36
+ if not value:
37
+ raise StackShiftAgentError(
38
+ "The agent has no run token yet. Calls to StackShift work while the agent handles an event.",
39
+ status=401,
40
+ code="token_missing",
41
+ )
42
+ with self._lock:
43
+ self._value = value
44
+ return value
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ from typing import Mapping
6
+
7
+ from cryptography.exceptions import InvalidSignature
8
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
9
+
10
+ MAX_AGE_SECONDS = 5 * 60
11
+ MAX_SKEW_SECONDS = 60
12
+
13
+
14
+ def verify_delivery(public_key: str, headers: Mapping[str, str], body: bytes, now: float) -> bool:
15
+ signature = headers.get("x-stackshift-signature")
16
+ timestamp = headers.get("x-stackshift-timestamp")
17
+ invocation = headers.get("x-stackshift-invocation")
18
+ if not signature or not timestamp or not invocation:
19
+ return False
20
+ try:
21
+ seconds = int(timestamp)
22
+ raw = base64.b64decode(public_key, validate=True)
23
+ signature_bytes = base64.b64decode(signature, validate=True)
24
+ except (ValueError, binascii.Error):
25
+ return False
26
+ age = now - seconds
27
+ if age > MAX_AGE_SECONDS or age < -MAX_SKEW_SECONDS or len(raw) != 32 or len(signature_bytes) != 64:
28
+ return False
29
+ try:
30
+ Ed25519PublicKey.from_public_bytes(raw).verify(signature_bytes, f"{timestamp}\n{invocation}\n".encode() + body)
31
+ except InvalidSignature:
32
+ return False
33
+ return True
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: stackshift-agent
3
+ Version: 0.1.0
4
+ Summary: Run an agent on StackShift: events, governed actions, StackShift calls, state and model access.
5
+ Author-email: StackShift <support@stackshift.cloud>
6
+ Project-URL: Homepage, https://stackshift.cloud
7
+ Project-URL: Documentation, https://docs.stackshift.cloud
8
+ Classifier: Programming Language :: Python :: 3
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: cryptography>=42
12
+ Provides-Extra: anthropic
13
+ Requires-Dist: anthropic>=0.30; extra == "anthropic"
14
+ Provides-Extra: openai
15
+ Requires-Dist: openai>=1.40; extra == "openai"
16
+ Provides-Extra: claude-agent-sdk
17
+ Requires-Dist: claude-agent-sdk>=0.1; extra == "claude-agent-sdk"
18
+ Provides-Extra: openai-agents
19
+ Requires-Dist: openai-agents>=0.1; extra == "openai-agents"
20
+
21
+ # stackshift-agent
22
+
23
+ Write an agent that runs on StackShift. The package receives the events that wake your agent, checks that StackShift sent them, runs your handlers, and gives your code the actions, StackShift calls, state and model access an agent needs.
24
+
25
+ ```bash
26
+ pip install stackshift-agent
27
+ ```
28
+
29
+ Python 3.9 or newer. The Anthropic and OpenAI clients and the two adapters use packages you install yourself (`anthropic`, `openai`, `claude-agent-sdk`, `openai-agents`). None of them is needed for the rest.
30
+
31
+ ## A first agent
32
+
33
+ ```python
34
+ from stackshift_agent import agent
35
+
36
+
37
+ @agent.action("lookup_order")
38
+ async def lookup_order(args):
39
+ return {"order_id": args["order_id"], "status": "shipped"}
40
+
41
+
42
+ @agent.on("inbox.message")
43
+ async def answer(event):
44
+ order = await lookup_order({"order_id": event.data["message"]["subject"]})
45
+ await agent.inbox.reply(event.data["message"], f"Your order is {order['status']}.")
46
+
47
+
48
+ agent.start()
49
+ ```
50
+
51
+ `agent.start()` listens on `PORT` (8080 when it is not set) and answers `POST /stackshift/events`. Declare `lookup_order` in the `agent:` block of `stackshift.yaml`; StackShift only lets an action run if it is declared there.
52
+
53
+ ## Events
54
+
55
+ `agent.on(event)` runs for each event of that name: `inbox.message`, `agreement.offer_received`, `agreement.agreed`, `agreement.declined`, `agreement.expired`, `agreement.waiting_for_you`, `a2a.message`, `schedule.tick`, `webhook.received`, `approval.decided` and `budget.warning`. The handler gets an object with `type`, `id`, `at`, `data`, `untrusted` and `run_id`. Handlers can be plain or `async` functions.
56
+
57
+ Each event is checked against `STACKSHIFT_EVENT_KEY` (the Ed25519 signature on the delivery) before any handler runs. Events that arrive twice are dropped by their id. A handler that raises ends the run with the outcome `failed`; a run that saved an action for approval ends `waiting`; anything else ends `done`.
58
+
59
+ ## Actions and approvals
60
+
61
+ `agent.action(key)` wraps your function. Each call asks StackShift first, with the arguments, and StackShift answers allowed, waiting or refused under the agent's level.
62
+
63
+ - **Allowed:** your function runs and its result is recorded.
64
+ - **Refused:** the call raises `ActionRefusedError` with a reason in plain words.
65
+ - **Waiting:** by default the call is saved in the agent's state, the run ends with the outcome `waiting`, and when the owner decides the call runs again with exactly the arguments they approved. Pass `wait=True` to hold the run for up to 55 seconds instead.
66
+
67
+ Mark text your agent fetched itself with `await agent.untrusted(text, "web:example.com")`. StackShift then asks before an action built from that text does anything that matters.
68
+
69
+ ## Calling StackShift
70
+
71
+ ```python
72
+ operation = await agent.platform.call("database.inspect", {"database_id": database_id}, reason="Check the size")
73
+ inbox = await agent.platform.inspect("mail.agent_inboxes.get", {"inbox_id": inbox_id})
74
+ ```
75
+
76
+ `call` proposes the action through the agent's connection and waits up to 55 seconds for it to settle (`wait=False` returns at once). The agent can only use the actions its owner allowed. `inspect` runs a read.
77
+
78
+ `agent.inbox` has `send`, `reply`, `info` and `id`. `agent.agreements` has `list`, `inspect`, `limits`, `verify`, `wait`, `propose`, `respond`, `cancel` and `count_reply`; they work when agreements are on for the agent's inbox.
79
+
80
+ ## State and locks
81
+
82
+ ```python
83
+ await agent.state.set("last_report", {"at": 1}, ttl_seconds=86400)
84
+ last = await agent.state.get("last_report")
85
+ async with agent.state.locked("nightly"):
86
+ ...
87
+ ```
88
+
89
+ ## Models
90
+
91
+ `agent.anthropic()` and `agent.openai()` return the official clients, already pointed at StackShift and signed in with the agent's run token; `agent.async_anthropic()` and `agent.async_openai()` return the async ones. There is no provider key in the agent. Pass `max_tokens` on every call.
92
+
93
+ ## Internet access
94
+
95
+ `await agent.fetch(url, method="GET", headers=None, body=None)` returns a response with `status`, `headers`, `text()` and `json()`. For a host where the owner saved a key to send on the way out, the request goes through StackShift, which adds the key. The agent never holds it.
96
+
97
+ ## Claude Agent SDK and OpenAI Agents SDK
98
+
99
+ ```python
100
+ from stackshift_agent.adapters.claude import claude_tools
101
+ from stackshift_agent.adapters.openai_agents import openai_tools
102
+
103
+ claude = await claude_tools(agent)
104
+ tools = await openai_tools(agent)
105
+ ```
106
+
107
+ `claude_tools` returns `mcp_servers` and `allowed_tools` for `ClaudeAgentOptions`. `openai_tools` returns `FunctionTool` objects for an `Agent`. Both turn your declared actions into tools with the approval step built in. A refusal or a wait comes back to the model as text, not as an error. Call them while handling an event.
108
+
109
+ ## What this does and does not enforce
110
+
111
+ The wrapper governs your agent's model, which is what goes wrong in practice. It is not a guard against the developer: you own the code, so you can skip it. What StackShift enforces whatever the code does: model calls through StackShift, actions on StackShift, email and agreements, and which internet addresses the agent can reach.
@@ -0,0 +1,23 @@
1
+ stackshift_agent/__init__.py,sha256=nQCvZwXZBClW1pd2lUE7sT_hWNgqVaUZZShC9N07oU8,774
2
+ stackshift_agent/actions.py,sha256=J1MuJVVeSn7nYKWz9gkuCPCBO6njnQsRjR9uuPMKxNY,4367
3
+ stackshift_agent/agent.py,sha256=B6gk9sGjM74I5a3eoUPzSDgZxcW4B3bhoxpR4_lbLHY,10016
4
+ stackshift_agent/agreements.py,sha256=ku0YAetNXwFZrWGE1eExlprpOouGbVwbZfHsyt9PdNU,2025
5
+ stackshift_agent/api.py,sha256=z4M9BDRMvCsFa6csS1Vj58bW2JEUyjf-Sr9vB1cerZs,1778
6
+ stackshift_agent/config.py,sha256=TO-RMO3sEWA6a2FyJObRPoEfgfoFG_lxYvj2ZBn-seQ,1498
7
+ stackshift_agent/egress.py,sha256=ZYdLRCX37W006hxz_PI00Yh40JvcPLL8zuMndoBqIL4,1199
8
+ stackshift_agent/errors.py,sha256=j3mzXOcMIM72xlgO8wEORE5m1ZIOi6WI3VDbALZvAxk,1295
9
+ stackshift_agent/http.py,sha256=ZetlIwlF0Dx_gUvZDqAUxhxWxWyIQG4r3gwOy7Za1hQ,1607
10
+ stackshift_agent/inbox.py,sha256=rSJ_6DLHiIKjk92zRNdkhGm8g6phAVNyCxLgd8XiKrI,2025
11
+ stackshift_agent/models.py,sha256=Vmezk0BqlXbCOfn08Pby106B1keqB1HUImmmRv8wzp0,1943
12
+ stackshift_agent/platform.py,sha256=95Zkuzv8ziEfDZcLq4sZi-kGLuHDOP5K896ElLJwZ6U,4954
13
+ stackshift_agent/run.py,sha256=ylFEGZtn5cKCIKsvO6JvKdOi-FJYKmHr4avBCRgpqhU,723
14
+ stackshift_agent/state.py,sha256=x1Ni5RssZKomAHA-fcU80jSYm1Ibc7EJGYLpxtgRudU,2262
15
+ stackshift_agent/tokens.py,sha256=ip1sHKiW9DHCxQbCH7qECEXJF7V9GFJZL2_7FwL4b14,1295
16
+ stackshift_agent/verify.py,sha256=Ppd3qs4diKSvIpv3xEshdgfw_0KtAwjKxaxmU1Jkbhg,1193
17
+ stackshift_agent/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ stackshift_agent/adapters/claude.py,sha256=MCLtCcIVUg6z-8KAyC-P8z6Qa5mpUSrfzNuXCprm8x4,1108
19
+ stackshift_agent/adapters/openai_agents.py,sha256=oMyGq2388LXExO_EkJgGC_sn8WUZGDiykyaaV1K-daE,807
20
+ stackshift_agent-0.1.0.dist-info/METADATA,sha256=2ilgTK3_MggIn9E-57L6vyaCPTAiqmCfBOc082FWR_w,5917
21
+ stackshift_agent-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
22
+ stackshift_agent-0.1.0.dist-info/top_level.txt,sha256=kIVOwIFILP3aEfJHBGGk71B64l-rIq-Dg7IGiaXZTOI,17
23
+ stackshift_agent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ stackshift_agent