stackshift-agent 0.1.0__tar.gz

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.
Files changed (33) hide show
  1. stackshift_agent-0.1.0/PKG-INFO +111 -0
  2. stackshift_agent-0.1.0/README.md +91 -0
  3. stackshift_agent-0.1.0/pyproject.toml +26 -0
  4. stackshift_agent-0.1.0/setup.cfg +4 -0
  5. stackshift_agent-0.1.0/stackshift_agent/__init__.py +32 -0
  6. stackshift_agent-0.1.0/stackshift_agent/actions.py +104 -0
  7. stackshift_agent-0.1.0/stackshift_agent/adapters/__init__.py +0 -0
  8. stackshift_agent-0.1.0/stackshift_agent/adapters/claude.py +26 -0
  9. stackshift_agent-0.1.0/stackshift_agent/adapters/openai_agents.py +25 -0
  10. stackshift_agent-0.1.0/stackshift_agent/agent.py +250 -0
  11. stackshift_agent-0.1.0/stackshift_agent/agreements.py +38 -0
  12. stackshift_agent-0.1.0/stackshift_agent/api.py +53 -0
  13. stackshift_agent-0.1.0/stackshift_agent/config.py +47 -0
  14. stackshift_agent-0.1.0/stackshift_agent/egress.py +32 -0
  15. stackshift_agent-0.1.0/stackshift_agent/errors.py +33 -0
  16. stackshift_agent-0.1.0/stackshift_agent/http.py +44 -0
  17. stackshift_agent-0.1.0/stackshift_agent/inbox.py +59 -0
  18. stackshift_agent-0.1.0/stackshift_agent/models.py +51 -0
  19. stackshift_agent-0.1.0/stackshift_agent/platform.py +115 -0
  20. stackshift_agent-0.1.0/stackshift_agent/run.py +38 -0
  21. stackshift_agent-0.1.0/stackshift_agent/state.py +55 -0
  22. stackshift_agent-0.1.0/stackshift_agent/tokens.py +44 -0
  23. stackshift_agent-0.1.0/stackshift_agent/verify.py +33 -0
  24. stackshift_agent-0.1.0/stackshift_agent.egg-info/PKG-INFO +111 -0
  25. stackshift_agent-0.1.0/stackshift_agent.egg-info/SOURCES.txt +31 -0
  26. stackshift_agent-0.1.0/stackshift_agent.egg-info/dependency_links.txt +1 -0
  27. stackshift_agent-0.1.0/stackshift_agent.egg-info/requires.txt +13 -0
  28. stackshift_agent-0.1.0/stackshift_agent.egg-info/top_level.txt +1 -0
  29. stackshift_agent-0.1.0/tests/test_actions.py +207 -0
  30. stackshift_agent-0.1.0/tests/test_adapters.py +110 -0
  31. stackshift_agent-0.1.0/tests/test_egress_models.py +91 -0
  32. stackshift_agent-0.1.0/tests/test_events.py +147 -0
  33. stackshift_agent-0.1.0/tests/test_platform.py +171 -0
@@ -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,91 @@
1
+ # stackshift-agent
2
+
3
+ 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.
4
+
5
+ ```bash
6
+ pip install stackshift-agent
7
+ ```
8
+
9
+ 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.
10
+
11
+ ## A first agent
12
+
13
+ ```python
14
+ from stackshift_agent import agent
15
+
16
+
17
+ @agent.action("lookup_order")
18
+ async def lookup_order(args):
19
+ return {"order_id": args["order_id"], "status": "shipped"}
20
+
21
+
22
+ @agent.on("inbox.message")
23
+ async def answer(event):
24
+ order = await lookup_order({"order_id": event.data["message"]["subject"]})
25
+ await agent.inbox.reply(event.data["message"], f"Your order is {order['status']}.")
26
+
27
+
28
+ agent.start()
29
+ ```
30
+
31
+ `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.
32
+
33
+ ## Events
34
+
35
+ `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.
36
+
37
+ 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`.
38
+
39
+ ## Actions and approvals
40
+
41
+ `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.
42
+
43
+ - **Allowed:** your function runs and its result is recorded.
44
+ - **Refused:** the call raises `ActionRefusedError` with a reason in plain words.
45
+ - **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.
46
+
47
+ 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.
48
+
49
+ ## Calling StackShift
50
+
51
+ ```python
52
+ operation = await agent.platform.call("database.inspect", {"database_id": database_id}, reason="Check the size")
53
+ inbox = await agent.platform.inspect("mail.agent_inboxes.get", {"inbox_id": inbox_id})
54
+ ```
55
+
56
+ `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.
57
+
58
+ `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.
59
+
60
+ ## State and locks
61
+
62
+ ```python
63
+ await agent.state.set("last_report", {"at": 1}, ttl_seconds=86400)
64
+ last = await agent.state.get("last_report")
65
+ async with agent.state.locked("nightly"):
66
+ ...
67
+ ```
68
+
69
+ ## Models
70
+
71
+ `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.
72
+
73
+ ## Internet access
74
+
75
+ `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.
76
+
77
+ ## Claude Agent SDK and OpenAI Agents SDK
78
+
79
+ ```python
80
+ from stackshift_agent.adapters.claude import claude_tools
81
+ from stackshift_agent.adapters.openai_agents import openai_tools
82
+
83
+ claude = await claude_tools(agent)
84
+ tools = await openai_tools(agent)
85
+ ```
86
+
87
+ `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.
88
+
89
+ ## What this does and does not enforce
90
+
91
+ 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,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "stackshift-agent"
7
+ version = "0.1.0"
8
+ description = "Run an agent on StackShift: events, governed actions, StackShift calls, state and model access."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [{name = "StackShift", email = "support@stackshift.cloud"}]
12
+ classifiers = ["Programming Language :: Python :: 3"]
13
+ dependencies = ["cryptography>=42"]
14
+
15
+ [project.optional-dependencies]
16
+ anthropic = ["anthropic>=0.30"]
17
+ openai = ["openai>=1.40"]
18
+ claude-agent-sdk = ["claude-agent-sdk>=0.1"]
19
+ openai-agents = ["openai-agents>=0.1"]
20
+
21
+ [project.urls]
22
+ Homepage = "https://stackshift.cloud"
23
+ Documentation = "https://docs.stackshift.cloud"
24
+
25
+ [tool.setuptools]
26
+ packages = ["stackshift_agent", "stackshift_agent.adapters"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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}"
@@ -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)