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.
Files changed (117) hide show
  1. switchplane-0.4.0/.github/workflows/www.yml +53 -0
  2. {switchplane-0.3.0 → switchplane-0.4.0}/CLAUDE.md +23 -3
  3. switchplane-0.3.0/README.md → switchplane-0.4.0/PKG-INFO +89 -0
  4. switchplane-0.3.0/PKG-INFO → switchplane-0.4.0/README.md +51 -36
  5. {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/agents/bot/tasks/chat.py +2 -2
  6. {switchplane-0.3.0 → switchplane-0.4.0}/pyproject.toml +2 -2
  7. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/agent_runtime.py +23 -0
  8. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/cli.py +25 -0
  9. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/fmt.py +28 -6
  10. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/protocol.py +4 -0
  11. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/subprocess_manager.py +4 -0
  12. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/tui.py +235 -44
  13. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_cli.py +4 -2
  14. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_protocol.py +4 -0
  15. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_tui.py +4 -1
  16. switchplane-0.4.0/www/.gitignore +3 -0
  17. switchplane-0.4.0/www/index.html +18 -0
  18. switchplane-0.4.0/www/package-lock.json +2826 -0
  19. switchplane-0.4.0/www/package.json +26 -0
  20. switchplane-0.4.0/www/postcss.config.js +6 -0
  21. switchplane-0.4.0/www/src/App.tsx +25 -0
  22. switchplane-0.4.0/www/src/components/Comparison.tsx +517 -0
  23. switchplane-0.4.0/www/src/components/Features.tsx +153 -0
  24. switchplane-0.4.0/www/src/components/Footer.tsx +14 -0
  25. switchplane-0.4.0/www/src/components/Hero.tsx +95 -0
  26. switchplane-0.4.0/www/src/components/LocalFirst.tsx +57 -0
  27. switchplane-0.4.0/www/src/components/Pillars.tsx +115 -0
  28. switchplane-0.4.0/www/src/components/Quickstart.tsx +108 -0
  29. switchplane-0.4.0/www/src/components/TerminalRecording.tsx +165 -0
  30. switchplane-0.4.0/www/src/main.tsx +10 -0
  31. switchplane-0.4.0/www/src/styles/index.css +19 -0
  32. switchplane-0.4.0/www/tailwind.config.js +36 -0
  33. switchplane-0.4.0/www/tsconfig.json +21 -0
  34. switchplane-0.4.0/www/vite.config.ts +10 -0
  35. {switchplane-0.3.0 → switchplane-0.4.0}/.github/labeler.yml +0 -0
  36. {switchplane-0.3.0 → switchplane-0.4.0}/.github/release.yml +0 -0
  37. {switchplane-0.3.0 → switchplane-0.4.0}/.github/workflows/ci.yml +0 -0
  38. {switchplane-0.3.0 → switchplane-0.4.0}/.github/workflows/labeler.yml +0 -0
  39. {switchplane-0.3.0 → switchplane-0.4.0}/.github/workflows/publish.yml +0 -0
  40. {switchplane-0.3.0 → switchplane-0.4.0}/.gitignore +0 -0
  41. {switchplane-0.3.0 → switchplane-0.4.0}/CODEOWNERS +0 -0
  42. {switchplane-0.3.0 → switchplane-0.4.0}/CODE_OF_CONDUCT.md +0 -0
  43. {switchplane-0.3.0 → switchplane-0.4.0}/CONTRIBUTING.md +0 -0
  44. {switchplane-0.3.0 → switchplane-0.4.0}/LICENSE +0 -0
  45. {switchplane-0.3.0 → switchplane-0.4.0}/Makefile +0 -0
  46. {switchplane-0.3.0 → switchplane-0.4.0}/SECURITY.md +0 -0
  47. {switchplane-0.3.0 → switchplane-0.4.0}/TODO.md +0 -0
  48. {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/__init__.py +0 -0
  49. {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/agents/__init__.py +0 -0
  50. {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/agents/bot/__init__.py +0 -0
  51. {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/agents/bot/agent.py +0 -0
  52. {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/agents/bot/tasks/__init__.py +0 -0
  53. {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/app.py +0 -0
  54. {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/chatbot/config.toml +0 -0
  55. {switchplane-0.3.0 → switchplane-0.4.0}/examples/chatbot/pyproject.toml +0 -0
  56. {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/__init__.py +0 -0
  57. {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/agents/__init__.py +0 -0
  58. {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/agents/sre/__init__.py +0 -0
  59. {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/agents/sre/agent.py +0 -0
  60. {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/agents/sre/tasks/__init__.py +0 -0
  61. {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/agents/sre/tasks/review.py +0 -0
  62. {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/app.py +0 -0
  63. {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/devops/config.toml +0 -0
  64. {switchplane-0.3.0 → switchplane-0.4.0}/examples/devops/pyproject.toml +0 -0
  65. {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/__init__.py +0 -0
  66. {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/agents/__init__.py +0 -0
  67. {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/agents/example/__init__.py +0 -0
  68. {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/agents/example/agent.py +0 -0
  69. {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/agents/example/tasks/__init__.py +0 -0
  70. {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/agents/example/tasks/hello.py +0 -0
  71. {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/hello/app.py +0 -0
  72. {switchplane-0.3.0 → switchplane-0.4.0}/examples/hello/pyproject.toml +0 -0
  73. {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/pyproject.toml +0 -0
  74. {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/__init__.py +0 -0
  75. {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/agents/__init__.py +0 -0
  76. {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/agents/weather/__init__.py +0 -0
  77. {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/agents/weather/agent.py +0 -0
  78. {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/agents/weather/tasks/__init__.py +0 -0
  79. {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/agents/weather/tasks/watch.py +0 -0
  80. {switchplane-0.3.0 → switchplane-0.4.0}/examples/weather/weather/app.py +0 -0
  81. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/__init__.py +0 -0
  82. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/__main__.py +0 -0
  83. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/_util.py +0 -0
  84. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/agent.py +0 -0
  85. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/app.py +0 -0
  86. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/checkpoint.py +0 -0
  87. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/config.py +0 -0
  88. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/control_plane.py +0 -0
  89. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/daemon.py +0 -0
  90. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/discovery.py +0 -0
  91. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/llm.py +0 -0
  92. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/logging.py +0 -0
  93. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/mcp.py +0 -0
  94. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/oauth.py +0 -0
  95. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/persistence.py +0 -0
  96. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/scaffold.py +0 -0
  97. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/shell.py +0 -0
  98. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/task.py +0 -0
  99. {switchplane-0.3.0 → switchplane-0.4.0}/src/switchplane/transport.py +0 -0
  100. {switchplane-0.3.0 → switchplane-0.4.0}/tests/__init__.py +0 -0
  101. {switchplane-0.3.0 → switchplane-0.4.0}/tests/conftest.py +0 -0
  102. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_agent.py +0 -0
  103. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_agent_runtime.py +0 -0
  104. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_app.py +0 -0
  105. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_checkpoint.py +0 -0
  106. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_config.py +0 -0
  107. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_control_plane.py +0 -0
  108. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_daemon.py +0 -0
  109. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_discovery.py +0 -0
  110. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_inter_task.py +0 -0
  111. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_llm.py +0 -0
  112. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_mcp.py +0 -0
  113. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_persistence.py +0 -0
  114. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_shell.py +0 -0
  115. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_subprocess_manager.py +0 -0
  116. {switchplane-0.3.0 → switchplane-0.4.0}/tests/test_task.py +0 -0
  117. {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. Discovery auto-registers the task from the `tasks/` subpackage no need to declare it in `AgentSpec`
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.progress(f"Assistant: {last_msg.content}")
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.progress(f"Assistant: {last_msg.content}")
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.3.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([(TS, f"[{ts}] "), (style, msg)]))
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
- for det in tree(payload.get("detail", [])):
53
- continuation(DIM, det)
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", {})
@@ -59,6 +59,10 @@ class AgentEvent(BaseModel):
59
59
  "checkpoint.save",
60
60
  "log",
61
61
  "task.command_result",
62
+ "stream.chunk",
63
+ "stream.flush",
64
+ "tool.invoke",
65
+ "tool.result",
62
66
  ]
63
67
  task_id: str
64
68
  payload: dict[str, Any] = Field(default_factory=dict)
@@ -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: