switchplane 0.3.0__tar.gz → 0.4.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.
- switchplane-0.4.0/.github/workflows/www.yml +53 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/CLAUDE.md +23 -3
- switchplane-0.3.0/README.md → switchplane-0.4.0/PKG-INFO +89 -0
- switchplane-0.3.0/PKG-INFO → switchplane-0.4.0/README.md +51 -36
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/agents/bot/tasks/chat.py +2 -2
- {switchplane-0.3.0 → switchplane-0.4.0}/pyproject.toml +2 -2
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/agent_runtime.py +23 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/cli.py +25 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/fmt.py +28 -6
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/protocol.py +4 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/subprocess_manager.py +4 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/tui.py +235 -44
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_cli.py +4 -2
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_protocol.py +4 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_tui.py +4 -1
- switchplane-0.4.0/www/.gitignore +3 -0
- switchplane-0.4.0/www/index.html +18 -0
- switchplane-0.4.0/www/package-lock.json +2826 -0
- switchplane-0.4.0/www/package.json +26 -0
- switchplane-0.4.0/www/postcss.config.js +6 -0
- switchplane-0.4.0/www/src/App.tsx +25 -0
- switchplane-0.4.0/www/src/components/Comparison.tsx +517 -0
- switchplane-0.4.0/www/src/components/Features.tsx +153 -0
- switchplane-0.4.0/www/src/components/Footer.tsx +14 -0
- switchplane-0.4.0/www/src/components/Hero.tsx +95 -0
- switchplane-0.4.0/www/src/components/LocalFirst.tsx +57 -0
- switchplane-0.4.0/www/src/components/Pillars.tsx +115 -0
- switchplane-0.4.0/www/src/components/Quickstart.tsx +108 -0
- switchplane-0.4.0/www/src/components/TerminalRecording.tsx +165 -0
- switchplane-0.4.0/www/src/main.tsx +10 -0
- switchplane-0.4.0/www/src/styles/index.css +19 -0
- switchplane-0.4.0/www/tailwind.config.js +36 -0
- switchplane-0.4.0/www/tsconfig.json +21 -0
- switchplane-0.4.0/www/vite.config.ts +10 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/.github/labeler.yml +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/.github/release.yml +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/.github/workflows/ci.yml +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/.github/workflows/labeler.yml +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/.github/workflows/publish.yml +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/.gitignore +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/CODEOWNERS +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/CODE_OF_CONDUCT.md +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/CONTRIBUTING.md +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/LICENSE +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/Makefile +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/SECURITY.md +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/TODO.md +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/agents/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/agents/bot/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/agents/bot/agent.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/agents/bot/tasks/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/app.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/config.toml +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/pyproject.toml +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/agents/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/agents/sre/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/agents/sre/agent.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/agents/sre/tasks/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/agents/sre/tasks/review.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/app.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/config.toml +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/pyproject.toml +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/agents/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/agents/example/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/agents/example/agent.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/agents/example/tasks/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/agents/example/tasks/hello.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/app.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/pyproject.toml +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/pyproject.toml +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/agents/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/agents/weather/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/agents/weather/agent.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/agents/weather/tasks/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/agents/weather/tasks/watch.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/app.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/__main__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/_util.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/agent.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/app.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/checkpoint.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/config.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/control_plane.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/daemon.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/discovery.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/llm.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/logging.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/mcp.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/oauth.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/persistence.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/scaffold.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/shell.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/task.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/transport.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/__init__.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/conftest.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_agent.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_agent_runtime.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_app.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_checkpoint.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_config.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_control_plane.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_daemon.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_discovery.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_inter_task.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_llm.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_mcp.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_persistence.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_shell.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_subprocess_manager.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_task.py +0 -0
- {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_transport.py +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: Deploy docs site to GitHub Pages
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
paths:
|
|
7
|
+
- "www/**"
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
pages: write
|
|
13
|
+
id-token: write
|
|
14
|
+
|
|
15
|
+
concurrency:
|
|
16
|
+
group: pages
|
|
17
|
+
cancel-in-progress: true
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
build:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- uses: actions/setup-node@v4
|
|
26
|
+
with:
|
|
27
|
+
node-version: "20"
|
|
28
|
+
cache: "npm"
|
|
29
|
+
cache-dependency-path: www/package-lock.json
|
|
30
|
+
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
working-directory: www
|
|
33
|
+
run: npm ci
|
|
34
|
+
|
|
35
|
+
- name: Build
|
|
36
|
+
working-directory: www
|
|
37
|
+
run: npm run build
|
|
38
|
+
|
|
39
|
+
- name: Upload artifact
|
|
40
|
+
uses: actions/upload-pages-artifact@v3
|
|
41
|
+
with:
|
|
42
|
+
path: www/dist
|
|
43
|
+
|
|
44
|
+
deploy:
|
|
45
|
+
needs: build
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
environment:
|
|
48
|
+
name: github-pages
|
|
49
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
50
|
+
steps:
|
|
51
|
+
- name: Deploy to GitHub Pages
|
|
52
|
+
id: deployment
|
|
53
|
+
uses: actions/deploy-pages@v4
|
|
@@ -33,6 +33,12 @@ src/switchplane/ # Main package (pip-installable)
|
|
|
33
33
|
|
|
34
34
|
mcp.py # MCP client lifecycle: McpSession, McpManager, LangChain tool wrapper
|
|
35
35
|
|
|
36
|
+
_util.py # Shared constants (MAX_MESSAGE_SIZE)
|
|
37
|
+
llm.py # LLM provider routing (ChatAnthropic/OpenAI/Google via model prefix)
|
|
38
|
+
logging.py # structlog configuration
|
|
39
|
+
oauth.py # OAuth client for MCP HTTP servers (PKCE flows)
|
|
40
|
+
scaffold.py # `switchplane init` project scaffolding
|
|
41
|
+
|
|
36
42
|
examples/hello/ # Simple LangGraph graph (get_user → say_hello)
|
|
37
43
|
examples/weather/ # Long-running polling task (Open-Meteo weather watch, @command for coordinates)
|
|
38
44
|
examples/devops/ # Ops review: deterministic pandas analysis + LLM summary
|
|
@@ -111,7 +117,7 @@ click, pydantic v2, aiosqlite, langgraph, prompt_toolkit, structlog. Optional: `
|
|
|
111
117
|
|
|
112
118
|
**CLI ↔ Control Plane:** Unix socket at `~/.{app_name}/runtime.sock`. Messages are 4-byte big-endian length prefix + JSON body. Types: `CliRequest` / `CliResponse`.
|
|
113
119
|
|
|
114
|
-
**Agent ↔ Control Plane:** Bidirectional over a per-agent Unix socketpair (`socket.socketpair(AF_UNIX)`). The CP creates the pair, passes one fd to the child via `--ipc-fd` + `pass_fds`. Same 4-byte length-prefixed JSON framing as CLI protocol. CP sends `AgentCommand`, agent sends `AgentEvent`. This allows mid-execution cancel/shutdown — the agent runs a command listener concurrently with task execution. stdout/stderr are freed for normal logging.
|
|
120
|
+
**Agent ↔ Control Plane:** Bidirectional over a per-agent Unix socketpair (`socket.socketpair(AF_UNIX)`). The CP creates the pair, passes one fd to the child via `--ipc-fd` + `pass_fds`. Same 4-byte length-prefixed JSON framing as CLI protocol. CP sends `AgentCommand`, agent sends `AgentEvent`. Agents can also send `AgentRequest` to the CP and receive `AgentResponse` — used for cross-task operations (submit, query, notify). This allows mid-execution cancel/shutdown — the agent runs a command listener concurrently with task execution. stdout/stderr are freed for normal logging.
|
|
115
121
|
|
|
116
122
|
## Database
|
|
117
123
|
|
|
@@ -124,11 +130,25 @@ SQLite at `~/.{app_name}/state.db` with WAL mode. Tables: `agents`, `tasks`, `ev
|
|
|
124
130
|
3. Declare parameters as class attributes using `Field()` from `switchplane` (re-exported from Pydantic). Parameters are validated before execution and set as instance attributes.
|
|
125
131
|
4. Implement `async def run(self, ctx: AgentContext)` — access params via `self.<param>`, build a LangGraph `StateGraph`, compile, `ainvoke`, then `ctx.complete(result)`
|
|
126
132
|
5. Optionally add `@command`-decorated methods for runtime commands on long-running tasks. Command parameters are typed and auto-coerced.
|
|
127
|
-
6.
|
|
133
|
+
6. Optionally declare `mcp_servers: ClassVar[list[str]] = ["server-name"]` on the task class to specify which MCP servers the task needs. Only declared servers are started for that task. If not set, all agent-level servers are used.
|
|
134
|
+
7. Discovery auto-registers the task from the `tasks/` subpackage — no need to declare it in `AgentSpec`
|
|
128
135
|
|
|
129
136
|
## MCP integration
|
|
130
137
|
|
|
131
|
-
MCP servers are registered at the app level via `McpServerConfig` (provide `command` for stdio, `url` for HTTP — transport is inferred). Agents declare which servers they need via `AgentSpec.mcp_servers`. The agent runtime manages client lifecycle (spawning stdio processes or connecting to HTTP endpoints) and exposes tools via `ctx.mcp` (raw sessions) and `ctx.mcp_tools()` (LangChain `StructuredTool` wrappers). MCP is an optional dependency: `pip install switchplane[mcp]`.
|
|
138
|
+
MCP servers are registered at the app level via `McpServerConfig` (provide `command` for stdio, `url` for HTTP — transport is inferred). Agents declare which servers they need via `AgentSpec.mcp_servers`. The agent runtime manages client lifecycle (spawning stdio processes or connecting to HTTP endpoints) and exposes tools via `ctx.mcp` (raw sessions) and `ctx.mcp_tools()` (LangChain `StructuredTool` wrappers). MCP is an optional dependency: `pip install switchplane[mcp]`. Tasks can also declare `mcp_servers` at the class level to restrict which servers are started for that specific task (instead of inheriting all servers from the agent).
|
|
139
|
+
|
|
140
|
+
## Cross-task coordination
|
|
141
|
+
|
|
142
|
+
Tasks can spawn child tasks, wait for their completion, and send notifications to sibling tasks via `AgentContext`:
|
|
143
|
+
|
|
144
|
+
- `ctx.submit_task(agent_name, task_name, params)` → submits a child task (linked via `parent_task_id`), returns the new `task_id`
|
|
145
|
+
- `ctx.get_task(task_id)` → returns current task record and events
|
|
146
|
+
- `ctx.wait_for_task(task_id)` → polls until terminal state, returns task record
|
|
147
|
+
- `ctx.wait_for_tasks(task_ids)` → parallel wait on multiple tasks
|
|
148
|
+
- `ctx.notify_task(task_id, payload)` → send a notification to another running task
|
|
149
|
+
- `ctx.wait_for_notification(timeout)` → block until a notification arrives
|
|
150
|
+
|
|
151
|
+
These requests travel over the existing agent↔CP socketpair as `AgentRequest`/`AgentResponse` messages. The control plane routes submissions and notifications across agents.
|
|
132
152
|
|
|
133
153
|
## Checkpoint and resume
|
|
134
154
|
|
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: switchplane
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Python runtime control plane for agent-based task execution, LangGraph-native
|
|
5
|
+
Project-URL: Homepage, https://github.com/salesforce-misc/switchplane
|
|
6
|
+
Project-URL: Repository, https://github.com/salesforce-misc/switchplane
|
|
7
|
+
Project-URL: Issues, https://github.com/salesforce-misc/switchplane/issues
|
|
8
|
+
Author-email: Demian Brecht <dbrecht@salesforce.com>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: aiosqlite>=0.20
|
|
19
|
+
Requires-Dist: click<9,>=8.3
|
|
20
|
+
Requires-Dist: langgraph>=1.0
|
|
21
|
+
Requires-Dist: prompt-toolkit>=3.0
|
|
22
|
+
Requires-Dist: pydantic<3,>=2.0
|
|
23
|
+
Requires-Dist: structlog>=24.0
|
|
24
|
+
Provides-Extra: llm
|
|
25
|
+
Requires-Dist: langchain-core>=0.3; extra == 'llm'
|
|
26
|
+
Requires-Dist: rich>=13.0; extra == 'llm'
|
|
27
|
+
Provides-Extra: mcp
|
|
28
|
+
Requires-Dist: langchain-core>=0.3; extra == 'mcp'
|
|
29
|
+
Requires-Dist: mcp>=1.0; extra == 'mcp'
|
|
30
|
+
Requires-Dist: rich>=13.0; extra == 'mcp'
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
33
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'test'
|
|
34
|
+
Requires-Dist: pytest-xdist>=3.5; extra == 'test'
|
|
35
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
36
|
+
Requires-Dist: ruff>=0.9; extra == 'test'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
1
39
|
# Switchplane
|
|
2
40
|
|
|
3
41
|
Most agent frameworks hand everything to the LLM and hope for the best. Switchplane takes a different position:
|
|
@@ -521,6 +559,19 @@ agent_spec = AgentSpec(
|
|
|
521
559
|
)
|
|
522
560
|
```
|
|
523
561
|
|
|
562
|
+
**Declare MCP servers on a task (optional):**
|
|
563
|
+
|
|
564
|
+
Tasks can override the agent-level default by declaring which specific servers they need. Only declared servers are started for that task:
|
|
565
|
+
|
|
566
|
+
```python
|
|
567
|
+
class MyTask(Task):
|
|
568
|
+
name = "analyze"
|
|
569
|
+
mcp_servers = ["my-tools"] # Only start this server, not all agent servers
|
|
570
|
+
|
|
571
|
+
async def run(self, ctx: AgentContext) -> None:
|
|
572
|
+
tools = await ctx.mcp_tools() # Only tools from "my-tools"
|
|
573
|
+
```
|
|
574
|
+
|
|
524
575
|
**Use MCP tools in your task:**
|
|
525
576
|
|
|
526
577
|
```python
|
|
@@ -625,6 +676,44 @@ llm_with_tools = llm.bind_tools(tools)
|
|
|
625
676
|
|
|
626
677
|
`Shell` uses `asyncio.create_subprocess_exec` (no shell interpretation), so arguments are never passed through a shell. The allowlist and path validation add defense-in-depth when LLM-generated values flow into command arguments.
|
|
627
678
|
|
|
679
|
+
For a general-purpose shell tool, `bash_tool()` returns a single `StructuredTool` that parses commands with `shlex` and validates against the allowlist. Working directory is locked to `allowed_paths[0]`, output is truncated to `max_output_chars` (default 30,000 characters). `agent_tools()` returns a minimal coding-focused set: bash + write_file + edit_file.
|
|
680
|
+
|
|
681
|
+
```python
|
|
682
|
+
# General-purpose bash tool
|
|
683
|
+
bash = shell.bash_tool()
|
|
684
|
+
|
|
685
|
+
# Minimal set for coding agents: bash + write_file + edit_file
|
|
686
|
+
tools = shell.agent_tools()
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
### Cross-task coordination
|
|
690
|
+
|
|
691
|
+
Tasks can spawn child tasks, wait for their completion, and send notifications to sibling tasks. Child tasks are linked via `parent_task_id`. Requests travel over the existing agent-CP socketpair as `AgentRequest`/`AgentResponse` messages.
|
|
692
|
+
|
|
693
|
+
```python
|
|
694
|
+
async def run(self, ctx: AgentContext) -> None:
|
|
695
|
+
# Spawn a child task (returns immediately with task_id)
|
|
696
|
+
child_id = await ctx.submit_task("worker", "process", {"chunk": 1})
|
|
697
|
+
|
|
698
|
+
# Wait for it to reach a terminal state
|
|
699
|
+
result = await ctx.wait_for_task(child_id)
|
|
700
|
+
|
|
701
|
+
# Or spawn multiple and wait in parallel
|
|
702
|
+
ids = [
|
|
703
|
+
await ctx.submit_task("worker", "process", {"chunk": i})
|
|
704
|
+
for i in range(3)
|
|
705
|
+
]
|
|
706
|
+
results = await ctx.wait_for_tasks(ids)
|
|
707
|
+
|
|
708
|
+
# Send a notification to another running task
|
|
709
|
+
await ctx.notify_task(other_task_id, {"status": "ready"})
|
|
710
|
+
|
|
711
|
+
# Block until a notification arrives (or timeout)
|
|
712
|
+
notification = await ctx.wait_for_notification(timeout=60.0)
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
`wait_for_task` polls until the child reaches a terminal state (completed, failed, cancelled). `wait_for_notification` wakes immediately when a notification arrives -- useful for event-driven coordination between long-running tasks.
|
|
716
|
+
|
|
628
717
|
### Checkpoint and resume
|
|
629
718
|
|
|
630
719
|
Tasks can opt into checkpointing so that failed or cancelled runs can be resumed from the last completed graph node. Switchplane provides a LangGraph-compatible checkpoint saver backed by the app's SQLite database. Pass it to `graph.compile()` and use `ctx.task_id` as the thread ID:
|
|
@@ -1,39 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: switchplane
|
|
3
|
-
Version: 0.3.0
|
|
4
|
-
Summary: Python runtime control plane for agent-based task execution, LangGraph-native
|
|
5
|
-
Project-URL: Homepage, https://github.com/salesforce-misc/switchplane
|
|
6
|
-
Project-URL: Repository, https://github.com/salesforce-misc/switchplane
|
|
7
|
-
Project-URL: Issues, https://github.com/salesforce-misc/switchplane/issues
|
|
8
|
-
Author-email: Demian Brecht <dbrecht@salesforce.com>
|
|
9
|
-
License: Apache-2.0
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
Classifier: Development Status :: 3 - Alpha
|
|
12
|
-
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
-
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
17
|
-
Requires-Python: >=3.12
|
|
18
|
-
Requires-Dist: aiosqlite>=0.20
|
|
19
|
-
Requires-Dist: click<9,>=8.3
|
|
20
|
-
Requires-Dist: langgraph>=1.0
|
|
21
|
-
Requires-Dist: prompt-toolkit>=3.0
|
|
22
|
-
Requires-Dist: pydantic<3,>=2.0
|
|
23
|
-
Requires-Dist: structlog>=24.0
|
|
24
|
-
Provides-Extra: llm
|
|
25
|
-
Requires-Dist: langchain-core>=0.3; extra == 'llm'
|
|
26
|
-
Provides-Extra: mcp
|
|
27
|
-
Requires-Dist: langchain-core>=0.3; extra == 'mcp'
|
|
28
|
-
Requires-Dist: mcp>=1.0; extra == 'mcp'
|
|
29
|
-
Provides-Extra: test
|
|
30
|
-
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
31
|
-
Requires-Dist: pytest-cov>=4.0; extra == 'test'
|
|
32
|
-
Requires-Dist: pytest-xdist>=3.5; extra == 'test'
|
|
33
|
-
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
34
|
-
Requires-Dist: ruff>=0.9; extra == 'test'
|
|
35
|
-
Description-Content-Type: text/markdown
|
|
36
|
-
|
|
37
1
|
# Switchplane
|
|
38
2
|
|
|
39
3
|
Most agent frameworks hand everything to the LLM and hope for the best. Switchplane takes a different position:
|
|
@@ -557,6 +521,19 @@ agent_spec = AgentSpec(
|
|
|
557
521
|
)
|
|
558
522
|
```
|
|
559
523
|
|
|
524
|
+
**Declare MCP servers on a task (optional):**
|
|
525
|
+
|
|
526
|
+
Tasks can override the agent-level default by declaring which specific servers they need. Only declared servers are started for that task:
|
|
527
|
+
|
|
528
|
+
```python
|
|
529
|
+
class MyTask(Task):
|
|
530
|
+
name = "analyze"
|
|
531
|
+
mcp_servers = ["my-tools"] # Only start this server, not all agent servers
|
|
532
|
+
|
|
533
|
+
async def run(self, ctx: AgentContext) -> None:
|
|
534
|
+
tools = await ctx.mcp_tools() # Only tools from "my-tools"
|
|
535
|
+
```
|
|
536
|
+
|
|
560
537
|
**Use MCP tools in your task:**
|
|
561
538
|
|
|
562
539
|
```python
|
|
@@ -661,6 +638,44 @@ llm_with_tools = llm.bind_tools(tools)
|
|
|
661
638
|
|
|
662
639
|
`Shell` uses `asyncio.create_subprocess_exec` (no shell interpretation), so arguments are never passed through a shell. The allowlist and path validation add defense-in-depth when LLM-generated values flow into command arguments.
|
|
663
640
|
|
|
641
|
+
For a general-purpose shell tool, `bash_tool()` returns a single `StructuredTool` that parses commands with `shlex` and validates against the allowlist. Working directory is locked to `allowed_paths[0]`, output is truncated to `max_output_chars` (default 30,000 characters). `agent_tools()` returns a minimal coding-focused set: bash + write_file + edit_file.
|
|
642
|
+
|
|
643
|
+
```python
|
|
644
|
+
# General-purpose bash tool
|
|
645
|
+
bash = shell.bash_tool()
|
|
646
|
+
|
|
647
|
+
# Minimal set for coding agents: bash + write_file + edit_file
|
|
648
|
+
tools = shell.agent_tools()
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
### Cross-task coordination
|
|
652
|
+
|
|
653
|
+
Tasks can spawn child tasks, wait for their completion, and send notifications to sibling tasks. Child tasks are linked via `parent_task_id`. Requests travel over the existing agent-CP socketpair as `AgentRequest`/`AgentResponse` messages.
|
|
654
|
+
|
|
655
|
+
```python
|
|
656
|
+
async def run(self, ctx: AgentContext) -> None:
|
|
657
|
+
# Spawn a child task (returns immediately with task_id)
|
|
658
|
+
child_id = await ctx.submit_task("worker", "process", {"chunk": 1})
|
|
659
|
+
|
|
660
|
+
# Wait for it to reach a terminal state
|
|
661
|
+
result = await ctx.wait_for_task(child_id)
|
|
662
|
+
|
|
663
|
+
# Or spawn multiple and wait in parallel
|
|
664
|
+
ids = [
|
|
665
|
+
await ctx.submit_task("worker", "process", {"chunk": i})
|
|
666
|
+
for i in range(3)
|
|
667
|
+
]
|
|
668
|
+
results = await ctx.wait_for_tasks(ids)
|
|
669
|
+
|
|
670
|
+
# Send a notification to another running task
|
|
671
|
+
await ctx.notify_task(other_task_id, {"status": "ready"})
|
|
672
|
+
|
|
673
|
+
# Block until a notification arrives (or timeout)
|
|
674
|
+
notification = await ctx.wait_for_notification(timeout=60.0)
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
`wait_for_task` polls until the child reaches a terminal state (completed, failed, cancelled). `wait_for_notification` wakes immediately when a notification arrives -- useful for event-driven coordination between long-running tasks.
|
|
678
|
+
|
|
664
679
|
### Checkpoint and resume
|
|
665
680
|
|
|
666
681
|
Tasks can opt into checkpointing so that failed or cancelled runs can be resumed from the last completed graph node. Switchplane provides a LangGraph-compatible checkpoint saver backed by the app's SQLite database. Pass it to `graph.compile()` and use `ctx.task_id` as the thread ID:
|
|
@@ -67,7 +67,7 @@ class ChatTask(Task):
|
|
|
67
67
|
# First turn: LLM responds to "Hello!", then graph interrupts at wait_for_user
|
|
68
68
|
result = await graph.ainvoke(initial_state, config)
|
|
69
69
|
last_msg = result["messages"][-1]
|
|
70
|
-
ctx.
|
|
70
|
+
ctx.stream_flush(last_msg.content)
|
|
71
71
|
|
|
72
72
|
# Chat loop
|
|
73
73
|
while not self._ending:
|
|
@@ -78,6 +78,6 @@ class ChatTask(Task):
|
|
|
78
78
|
# Resume the graph: wait_for_user returns with user text, then respond runs
|
|
79
79
|
result = await graph.ainvoke(Command(resume=user_input), config)
|
|
80
80
|
last_msg = result["messages"][-1]
|
|
81
|
-
ctx.
|
|
81
|
+
ctx.stream_flush(last_msg.content)
|
|
82
82
|
|
|
83
83
|
ctx.complete({"turns": len(result.get("messages", [])) // 2})
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "switchplane"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Python runtime control plane for agent-based task execution, LangGraph-native"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
@@ -36,7 +36,7 @@ Repository = "https://github.com/salesforce-misc/switchplane"
|
|
|
36
36
|
Issues = "https://github.com/salesforce-misc/switchplane/issues"
|
|
37
37
|
|
|
38
38
|
[project.optional-dependencies]
|
|
39
|
-
llm = ["langchain-core>=0.3"]
|
|
39
|
+
llm = ["langchain-core>=0.3", "rich>=13.0"]
|
|
40
40
|
mcp = ["mcp>=1.0", "switchplane[llm]"]
|
|
41
41
|
test = ["pytest>=7.0", "pytest-asyncio>=0.23", "pytest-cov>=4.0", "pytest-xdist>=3.5", "ruff>=0.9"]
|
|
42
42
|
|
|
@@ -188,6 +188,28 @@ class AgentContext:
|
|
|
188
188
|
)
|
|
189
189
|
_write_message_sync(self._sock, event.model_dump_json().encode())
|
|
190
190
|
|
|
191
|
+
def stream_token(self, text: str) -> None:
|
|
192
|
+
"""Emit a streaming text chunk (ephemeral, not persisted)."""
|
|
193
|
+
self.emit("stream.chunk", {"text": text})
|
|
194
|
+
|
|
195
|
+
def stream_flush(self, text: str) -> None:
|
|
196
|
+
"""End a streaming sequence. The text is the final complete output that replaces accumulated chunks."""
|
|
197
|
+
self.emit("stream.flush", {"text": text})
|
|
198
|
+
|
|
199
|
+
def tool_invoke(self, name: str, summary: str = "") -> None:
|
|
200
|
+
"""Emit a tool invocation event."""
|
|
201
|
+
payload: dict[str, Any] = {"name": name}
|
|
202
|
+
if summary:
|
|
203
|
+
payload["summary"] = summary
|
|
204
|
+
self.emit("tool.invoke", payload)
|
|
205
|
+
|
|
206
|
+
def tool_result(self, name: str, summary: str = "") -> None:
|
|
207
|
+
"""Emit a tool result event."""
|
|
208
|
+
payload: dict[str, Any] = {"name": name}
|
|
209
|
+
if summary:
|
|
210
|
+
payload["summary"] = summary
|
|
211
|
+
self.emit("tool.result", payload)
|
|
212
|
+
|
|
191
213
|
def progress(self, message: str, detail: str | list[str] | None = None, **extra) -> None:
|
|
192
214
|
payload: dict[str, Any] = {"message": message, **extra}
|
|
193
215
|
if detail is not None:
|
|
@@ -501,6 +523,7 @@ async def _start_checkpointer(ctx: AgentContext) -> None:
|
|
|
501
523
|
ctx._db_conn = await aiosqlite.connect(ctx._db_path)
|
|
502
524
|
ctx._db_conn.row_factory = aiosqlite.Row
|
|
503
525
|
await ctx._db_conn.execute("PRAGMA journal_mode=WAL")
|
|
526
|
+
await ctx._db_conn.execute("PRAGMA busy_timeout=5000")
|
|
504
527
|
saver = SqliteCheckpointSaver(ctx._db_conn)
|
|
505
528
|
await saver.setup()
|
|
506
529
|
ctx._checkpointer = saver
|
|
@@ -590,6 +590,31 @@ def _follow_task(task_id: str, send_request) -> None:
|
|
|
590
590
|
|
|
591
591
|
def _print_event(event: dict) -> None:
|
|
592
592
|
"""Format and print a single event using the shared renderer."""
|
|
593
|
+
etype = event.get("event_type", "")
|
|
594
|
+
|
|
595
|
+
# stream.chunk: print inline without newline
|
|
596
|
+
if etype == "stream.chunk":
|
|
597
|
+
text = event.get("payload", {}).get("text", "")
|
|
598
|
+
sys.stdout.write(text)
|
|
599
|
+
sys.stdout.flush()
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
# stream.flush: end the streaming line, then render final text as markdown
|
|
603
|
+
if etype == "stream.flush":
|
|
604
|
+
sys.stdout.write("\n")
|
|
605
|
+
sys.stdout.flush()
|
|
606
|
+
text = event.get("payload", {}).get("text", "")
|
|
607
|
+
if text.strip():
|
|
608
|
+
try:
|
|
609
|
+
from rich.console import Console as RichConsole
|
|
610
|
+
from rich.markdown import Markdown
|
|
611
|
+
|
|
612
|
+
RichConsole().print(Markdown(text))
|
|
613
|
+
except ImportError:
|
|
614
|
+
for part in text.split("\n"):
|
|
615
|
+
click.echo(f" {part}")
|
|
616
|
+
return
|
|
617
|
+
|
|
593
618
|
for line in fmt.render_event(event):
|
|
594
619
|
text = "".join(seg[1] for seg in line.segments)
|
|
595
620
|
is_dim = all(s in (fmt.DIM, fmt.TS) for s, _ in line.segments)
|
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
|
|
6
6
|
# Semantic style names — consumers map these to their output format
|
|
7
|
-
TS = "ts"
|
|
7
|
+
TS = "ts" # kept for backward compat; no longer emitted
|
|
8
8
|
INFO = "info"
|
|
9
9
|
DIM = "dim"
|
|
10
10
|
PROGRESS = "progress"
|
|
@@ -12,6 +12,10 @@ SUCCESS = "success"
|
|
|
12
12
|
WARN = "warn"
|
|
13
13
|
ERROR = "error"
|
|
14
14
|
LOG = "log"
|
|
15
|
+
STREAM = "stream"
|
|
16
|
+
TOOL = "tool"
|
|
17
|
+
|
|
18
|
+
_MAX_DETAIL_LINES = 20
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
@dataclass
|
|
@@ -28,15 +32,13 @@ def render_event(event: dict) -> list[EventLine]:
|
|
|
28
32
|
style constants defined in this module. Consumers map these to
|
|
29
33
|
their output format (click.echo, prompt_toolkit styled tuples, etc.).
|
|
30
34
|
"""
|
|
31
|
-
raw_ts = event.get("timestamp", "")
|
|
32
|
-
ts = raw_ts.split("T")[1].split(".")[0] if "T" in raw_ts else raw_ts
|
|
33
35
|
etype = event.get("event_type", "")
|
|
34
36
|
payload = event.get("payload", {})
|
|
35
37
|
|
|
36
38
|
lines: list[EventLine] = []
|
|
37
39
|
|
|
38
40
|
def main_line(style: str, msg: str) -> None:
|
|
39
|
-
lines.append(EventLine([(
|
|
41
|
+
lines.append(EventLine([(style, msg)]))
|
|
40
42
|
|
|
41
43
|
def continuation(style: str, text: str) -> None:
|
|
42
44
|
lines.append(EventLine([(style, f" {text}")]))
|
|
@@ -49,8 +51,14 @@ def render_event(event: dict) -> list[EventLine]:
|
|
|
49
51
|
main_line(PROGRESS, parts[0])
|
|
50
52
|
for cont in parts[1:]:
|
|
51
53
|
continuation(PROGRESS, cont)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
detail_lines = tree(payload.get("detail", []))
|
|
55
|
+
if len(detail_lines) > _MAX_DETAIL_LINES:
|
|
56
|
+
for det in detail_lines[:_MAX_DETAIL_LINES]:
|
|
57
|
+
continuation(DIM, det)
|
|
58
|
+
continuation(DIM, f"[+{len(detail_lines) - _MAX_DETAIL_LINES} lines]")
|
|
59
|
+
else:
|
|
60
|
+
for det in detail_lines:
|
|
61
|
+
continuation(DIM, det)
|
|
54
62
|
elif etype == "task.completed":
|
|
55
63
|
main_line(SUCCESS, "Task completed")
|
|
56
64
|
elif etype == "task.cancelled":
|
|
@@ -86,6 +94,20 @@ def render_event(event: dict) -> list[EventLine]:
|
|
|
86
94
|
main_line(style, parts[0])
|
|
87
95
|
for cont in parts[1:]:
|
|
88
96
|
continuation(style, cont)
|
|
97
|
+
elif etype == "tool.invoke":
|
|
98
|
+
name = payload.get("name", "unknown")
|
|
99
|
+
summary = payload.get("summary", "")
|
|
100
|
+
if summary:
|
|
101
|
+
main_line(TOOL, f"\u25b8 {name}: {summary}")
|
|
102
|
+
else:
|
|
103
|
+
main_line(TOOL, f"\u25b8 {name}")
|
|
104
|
+
elif etype == "tool.result":
|
|
105
|
+
name = payload.get("name", "unknown")
|
|
106
|
+
summary = payload.get("summary", "")
|
|
107
|
+
if summary:
|
|
108
|
+
main_line(TOOL, f"\u25c2 {name}: {summary}")
|
|
109
|
+
else:
|
|
110
|
+
main_line(TOOL, f"\u25c2 {name}")
|
|
89
111
|
elif etype == "task.command_result":
|
|
90
112
|
action = payload.get("action", "unknown")
|
|
91
113
|
result = payload.get("result", {})
|
|
@@ -307,6 +307,10 @@ class SubprocessManager:
|
|
|
307
307
|
|
|
308
308
|
async def _handle_event(self, event: AgentEvent) -> int:
|
|
309
309
|
"""Process an event from an agent and update persistence. Returns event_id."""
|
|
310
|
+
# stream.chunk events are ephemeral — skip DB persistence
|
|
311
|
+
if event.type == "stream.chunk":
|
|
312
|
+
return 0
|
|
313
|
+
|
|
310
314
|
event_id = await self.store.add_event(event.task_id, event.type, event.payload)
|
|
311
315
|
|
|
312
316
|
match event.type:
|