Merge pull request #17 from HarnessLab/feature/readme-update-april-2026
Implemented the next parity slice.
This commit is contained in:
+13
-12
@@ -299,10 +299,12 @@ Done:
|
|||||||
- [x] `web_search`
|
- [x] `web_search`
|
||||||
- [x] `tool_search`
|
- [x] `tool_search`
|
||||||
- [x] `sleep`
|
- [x] `sleep`
|
||||||
|
- [x] `ask_user_question`
|
||||||
- [x] `account_status`
|
- [x] `account_status`
|
||||||
- [x] `account_list_profiles`
|
- [x] `account_list_profiles`
|
||||||
- [x] `account_login`
|
- [x] `account_login`
|
||||||
- [x] `account_logout`
|
- [x] `account_logout`
|
||||||
|
- [x] `notebook_edit`
|
||||||
- [x] `mcp_list_resources`
|
- [x] `mcp_list_resources`
|
||||||
- [x] `mcp_read_resource`
|
- [x] `mcp_read_resource`
|
||||||
- [x] `mcp_list_tools`
|
- [x] `mcp_list_tools`
|
||||||
@@ -328,20 +330,22 @@ Done:
|
|||||||
- [x] `task_block`
|
- [x] `task_block`
|
||||||
- [x] `task_cancel`
|
- [x] `task_cancel`
|
||||||
- [x] `todo_write`
|
- [x] `todo_write`
|
||||||
|
- [x] `team_list`
|
||||||
|
- [x] `team_get`
|
||||||
|
- [x] `team_create`
|
||||||
|
- [x] `team_delete`
|
||||||
|
- [x] `send_message`
|
||||||
|
- [x] `team_messages`
|
||||||
|
|
||||||
Missing:
|
Missing:
|
||||||
|
|
||||||
- [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool
|
- [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool
|
||||||
- [ ] Skill tool
|
- [ ] Skill tool
|
||||||
- [ ] Notebook edit tool
|
|
||||||
- [ ] Web fetch parity beyond the current local text-fetch implementation
|
- [ ] Web fetch parity beyond the current local text-fetch implementation
|
||||||
- [ ] Web search parity beyond the current provider-backed implementation
|
- [ ] Web search parity beyond the current provider-backed implementation
|
||||||
- [ ] Ask-user-question tool
|
|
||||||
- [ ] LSP tool
|
- [ ] LSP tool
|
||||||
- [ ] Tool search parity beyond the current local registry search
|
- [ ] Tool search parity beyond the current local registry search
|
||||||
- [ ] Config tool
|
- [ ] Config tool
|
||||||
- [ ] Team create/delete tools
|
|
||||||
- [ ] Send-message tool
|
|
||||||
- [ ] Terminal capture tool
|
- [ ] Terminal capture tool
|
||||||
- [ ] Browser tool
|
- [ ] Browser tool
|
||||||
- [ ] Workflow tool
|
- [ ] Workflow tool
|
||||||
@@ -364,13 +368,15 @@ Done:
|
|||||||
- [x] Local dependency-aware task execution flow with next-task selection and blocked/unblocked state
|
- [x] Local dependency-aware task execution flow with next-task selection and blocked/unblocked state
|
||||||
- [x] Local remote profile/runtime flow with persisted connect/disconnect state
|
- [x] Local remote profile/runtime flow with persisted connect/disconnect state
|
||||||
- [x] Local background task management for agent worker sessions
|
- [x] Local background task management for agent worker sessions
|
||||||
|
- [x] Local ask-user runtime with queued answers, history, and slash/CLI inspection flows
|
||||||
|
- [x] Local team runtime with persisted teams, messages, and slash/CLI inspection flows
|
||||||
|
|
||||||
Missing:
|
Missing:
|
||||||
|
|
||||||
- [ ] Real implementation of the larger upstream command tree
|
- [ ] Real implementation of the larger upstream command tree
|
||||||
- [ ] Task orchestration system beyond the current local dependency-aware task runtime
|
- [ ] Task orchestration system beyond the current local dependency-aware task runtime
|
||||||
- [ ] Planner / task execution parity beyond the current local plan persistence, sync, and next-task flow
|
- [ ] Planner / task execution parity beyond the current local plan persistence, sync, and next-task flow
|
||||||
- [ ] Team / collaboration command flows
|
- [ ] Team / collaboration command flows beyond the current local team runtime and message recording flows
|
||||||
- [ ] Command-specific session behaviors
|
- [ ] Command-specific session behaviors
|
||||||
- [ ] Full `src/commands/*` parity
|
- [ ] Full `src/commands/*` parity
|
||||||
- [ ] Full `src/tasks/*` parity
|
- [ ] Full `src/tasks/*` parity
|
||||||
@@ -532,12 +538,8 @@ Mirrored inventory / scaffold areas that still need real implementation work:
|
|||||||
- [ ] `src/tools.py`
|
- [ ] `src/tools.py`
|
||||||
- [ ] `src/query_engine.py`
|
- [ ] `src/query_engine.py`
|
||||||
- [ ] `src/runtime.py`
|
- [ ] `src/runtime.py`
|
||||||
- [ ] `src/services/*`
|
- [ ] Remaining mirrored inventory surfaces still represented mainly by snapshot data under `src/reference_data/*`
|
||||||
- [ ] `src/plugins/*`
|
- [ ] Command/task/plugin/skill/service/editor subsystems that exist upstream but do not yet have real Python modules after the tree cleanup
|
||||||
- [ ] `src/remote/*`
|
|
||||||
- [ ] `src/voice/*`
|
|
||||||
- [ ] `src/vim/*`
|
|
||||||
- [ ] Large parts of the rest of the mirrored package tree
|
|
||||||
|
|
||||||
## 15. High-Priority Next Steps
|
## 15. High-Priority Next Steps
|
||||||
|
|
||||||
@@ -546,7 +548,6 @@ Mirrored inventory / scaffold areas that still need real implementation work:
|
|||||||
- [ ] Expand MCP parity beyond the current stdio resource/tool transport support
|
- [ ] Expand MCP parity beyond the current stdio resource/tool transport support
|
||||||
- [ ] Expand hooks and policy parity beyond the current manifest/runtime implementation
|
- [ ] Expand hooks and policy parity beyond the current manifest/runtime implementation
|
||||||
- [ ] Build a real interactive REPL / TUI
|
- [ ] Build a real interactive REPL / TUI
|
||||||
- [ ] Add tokenizer-accurate context accounting
|
|
||||||
- [ ] Expand background session parity beyond the current local worker/log/attach model
|
- [ ] Expand background session parity beyond the current local worker/log/attach model
|
||||||
- [ ] Add real remote session transport and shared remote state beyond the current local remote-profile runtime
|
- [ ] Add real remote session transport and shared remote state beyond the current local remote-profile runtime
|
||||||
- [ ] Port more of the command/task system
|
- [ ] Port more of the command/task system
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
| 🆕 | **MCP Transport** | Real stdio MCP transport for `initialize`, resource listing/reading, and tool listing/calling |
|
| 🆕 | **MCP Transport** | Real stdio MCP transport for `initialize`, resource listing/reading, and tool listing/calling |
|
||||||
| 🆕 | **Search Runtime** | Provider-backed `web_search` with local manifests, activation state, and `/search` flows |
|
| 🆕 | **Search Runtime** | Provider-backed `web_search` with local manifests, activation state, and `/search` flows |
|
||||||
| 🆕 | **Config & Account Runtime** | Local config/settings mutation plus manifest-backed account profiles and login/logout state |
|
| 🆕 | **Config & Account Runtime** | Local config/settings mutation plus manifest-backed account profiles and login/logout state |
|
||||||
|
| 🆕 | **Ask-User Runtime** | Queued or interactive local ask-user flow with history, slash commands, and agent tool support |
|
||||||
|
| 🆕 | **Team Runtime** | Persisted local teams and message history with team/message tools and slash/CLI inspection |
|
||||||
|
| 🆕 | **Notebook Edit Tool** | Native `.ipynb` cell editing through the real agent tool registry |
|
||||||
| 🆕 | **Tokenizer-Aware Context** | Cached tokenizer backends with heuristic fallback for `/context`, `/status`, and compaction |
|
| 🆕 | **Tokenizer-Aware Context** | Cached tokenizer backends with heuristic fallback for `/context`, `/status`, and compaction |
|
||||||
| 🆕 | **Daemon Commands** | Local `daemon start/ps/logs/attach/kill` wrapper over background agent sessions |
|
| 🆕 | **Daemon Commands** | Local `daemon start/ps/logs/attach/kill` wrapper over background agent sessions |
|
||||||
| 🆕 | **Background Sessions** | Local `agent-bg`, `agent-ps`, `agent-logs`, `agent-attach`, and `agent-kill` flows |
|
| 🆕 | **Background Sessions** | Local `agent-bg`, `agent-ps`, `agent-logs`, `agent-attach`, and `agent-kill` flows |
|
||||||
@@ -86,6 +89,9 @@ Built on the public porting workspace from [instructkr/claw-code](https://github
|
|||||||
| 🛰️ **MCP Runtime** | Local MCP manifests plus real stdio MCP transport for resources and tools |
|
| 🛰️ **MCP Runtime** | Local MCP manifests plus real stdio MCP transport for resources and tools |
|
||||||
| 🔎 **Search Runtime** | Provider-backed `web_search` plus provider activation and status reporting |
|
| 🔎 **Search Runtime** | Provider-backed `web_search` plus provider activation and status reporting |
|
||||||
| ⚙️ **Config & Account Runtime** | Local config mutation, settings inspection, account profiles, and login/logout state |
|
| ⚙️ **Config & Account Runtime** | Local config mutation, settings inspection, account profiles, and login/logout state |
|
||||||
|
| 🙋 **Ask-User Runtime** | Queued answer or interactive user-question flow with history tracking |
|
||||||
|
| 👥 **Team Runtime** | Persisted local teams plus message history, handoff notes, and collaboration metadata |
|
||||||
|
| 📓 **Notebook Editing** | Native Jupyter notebook cell editing through `notebook_edit` |
|
||||||
| 🪝 **Hook & Policy Runtime** | Trust reporting, safe env, managed settings, tool blocking, and budget overrides |
|
| 🪝 **Hook & Policy Runtime** | Trust reporting, safe env, managed settings, tool blocking, and budget overrides |
|
||||||
| 🧠 **Context Engine** | Automatic context building with CLAUDE.md discovery, compaction, and snipping |
|
| 🧠 **Context Engine** | Automatic context building with CLAUDE.md discovery, compaction, and snipping |
|
||||||
| 🔢 **Tokenizer-Aware Accounting** | Model-aware token counting with cached tokenizer backends and fallback heuristics |
|
| 🔢 **Tokenizer-Aware Accounting** | Model-aware token counting with cached tokenizer backends and fallback heuristics |
|
||||||
@@ -137,9 +143,12 @@ Built on the public porting workspace from [instructkr/claw-code](https://github
|
|||||||
- [x] Local hook and policy runtime with trust reporting, safe env, tool blocking, and budget overrides
|
- [x] Local hook and policy runtime with trust reporting, safe env, tool blocking, and budget overrides
|
||||||
- [x] Local config runtime: config discovery, effective settings, source inspection, and config mutation
|
- [x] Local config runtime: config discovery, effective settings, source inspection, and config mutation
|
||||||
- [x] Local account runtime: profile discovery, login/logout state, and account CLI/slash flows
|
- [x] Local account runtime: profile discovery, login/logout state, and account CLI/slash flows
|
||||||
|
- [x] Local ask-user runtime: queued answers, history, and ask-user CLI/slash flows
|
||||||
|
- [x] Local team runtime: persisted teams, team messages, and team CLI/slash flows
|
||||||
- [x] Local search runtime with provider discovery, activation, and provider-backed `web_search`
|
- [x] Local search runtime with provider discovery, activation, and provider-backed `web_search`
|
||||||
- [x] Local MCP runtime: manifest resources, stdio transport, MCP resources, and MCP tool calls
|
- [x] Local MCP runtime: manifest resources, stdio transport, MCP resources, and MCP tool calls
|
||||||
- [x] Local task and plan runtimes with plan sync and dependency-aware task execution
|
- [x] Local task and plan runtimes with plan sync and dependency-aware task execution
|
||||||
|
- [x] Notebook edit tool in the real Python tool registry
|
||||||
- [x] Tokenizer-aware context accounting with cached tokenizer backends and heuristic fallback
|
- [x] Tokenizer-aware context accounting with cached tokenizer backends and heuristic fallback
|
||||||
- [x] Plugin runtime: manifest discovery, hooks, aliases, virtual tools, tool blocking
|
- [x] Plugin runtime: manifest discovery, hooks, aliases, virtual tools, tool blocking
|
||||||
- [x] Plugin lifecycle hooks: resume, persist, delegate phases
|
- [x] Plugin lifecycle hooks: resume, persist, delegate phases
|
||||||
@@ -175,7 +184,7 @@ claw-code/
|
|||||||
├── .gitignore
|
├── .gitignore
|
||||||
├── images/
|
├── images/
|
||||||
│ └── logo.png
|
│ └── logo.png
|
||||||
├── src/ # Python implementation (75+ modules)
|
├── src/ # Python implementation
|
||||||
│ ├── main.py # CLI entry point & argument parsing
|
│ ├── main.py # CLI entry point & argument parsing
|
||||||
│ ├── agent_runtime.py # Core agent loop (LocalCodingAgent)
|
│ ├── agent_runtime.py # Core agent loop (LocalCodingAgent)
|
||||||
│ ├── agent_tools.py # Tool definitions & execution engine
|
│ ├── agent_tools.py # Tool definitions & execution engine
|
||||||
@@ -197,21 +206,20 @@ claw-code/
|
|||||||
│ ├── remote_runtime.py # Local remote profiles, connect/disconnect state, remote CLI support
|
│ ├── remote_runtime.py # Local remote profiles, connect/disconnect state, remote CLI support
|
||||||
│ ├── background_runtime.py # Local background sessions and daemon support
|
│ ├── background_runtime.py # Local background sessions and daemon support
|
||||||
│ ├── account_runtime.py # Local account profiles, login/logout state, account CLI support
|
│ ├── account_runtime.py # Local account profiles, login/logout state, account CLI support
|
||||||
|
│ ├── ask_user_runtime.py # Local ask-user queued answers and interaction history
|
||||||
│ ├── config_runtime.py # Local workspace config/settings discovery and mutation
|
│ ├── config_runtime.py # Local workspace config/settings discovery and mutation
|
||||||
│ ├── plan_runtime.py # Persistent plan runtime and plan sync
|
│ ├── plan_runtime.py # Persistent plan runtime and plan sync
|
||||||
│ ├── task_runtime.py # Persistent task runtime and task execution
|
│ ├── task_runtime.py # Persistent task runtime and task execution
|
||||||
│ ├── task.py # Task state model and task dataclasses
|
│ ├── task.py # Task state model and task dataclasses
|
||||||
|
│ ├── team_runtime.py # Local teams, messages, and collaboration metadata
|
||||||
│ ├── hook_policy.py # Hook/policy manifests, trust, and safe env handling
|
│ ├── hook_policy.py # Hook/policy manifests, trust, and safe env handling
|
||||||
│ ├── tokenizer_runtime.py # Tokenizer-aware context accounting backends
|
│ ├── tokenizer_runtime.py # Tokenizer-aware context accounting backends
|
||||||
│ ├── permissions.py # Tool permission filtering
|
│ ├── permissions.py # Tool permission filtering
|
||||||
│ ├── cost_tracker.py # Cost & budget enforcement
|
│ ├── cost_tracker.py # Cost & budget enforcement
|
||||||
│ ├── tools.py # Mirrored tool inventory
|
|
||||||
│ ├── commands.py # Mirrored command inventory
|
│ ├── commands.py # Mirrored command inventory
|
||||||
│ ├── plugins/ # Plugin subsystem
|
│ ├── tools.py # Mirrored tool inventory
|
||||||
│ ├── hooks/ # Hook system (WIP)
|
│ ├── runtime.py # Mirrored runtime facade
|
||||||
│ ├── remote/ # Remote runtime modes (WIP)
|
│ └── reference_data/ # Mirrored inventory snapshots
|
||||||
│ ├── voice/ # Voice mode (WIP)
|
|
||||||
│ └── vim/ # VIM mode (WIP)
|
|
||||||
└── tests/ # Unit tests
|
└── tests/ # Unit tests
|
||||||
├── test_agent_runtime.py
|
├── test_agent_runtime.py
|
||||||
├── test_agent_context.py
|
├── test_agent_context.py
|
||||||
|
|||||||
+148
-1
@@ -71,6 +71,8 @@ export OPENAI_API_KEY=anything
|
|||||||
export OPENAI_MODEL=ollama/qwen3
|
export OPENAI_MODEL=ollama/qwen3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If your cluster wraps `python3`, use an explicit interpreter path such as `/usr/bin/python3.9 -m ...` for the commands below.
|
||||||
|
|
||||||
### 1.5 Run the full unit test suite
|
### 1.5 Run the full unit test suite
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -90,6 +92,8 @@ python3 -m unittest tests.test_background_runtime -v
|
|||||||
python3 -m unittest tests.test_remote_runtime -v
|
python3 -m unittest tests.test_remote_runtime -v
|
||||||
python3 -m unittest tests.test_config_runtime -v
|
python3 -m unittest tests.test_config_runtime -v
|
||||||
python3 -m unittest tests.test_account_runtime -v
|
python3 -m unittest tests.test_account_runtime -v
|
||||||
|
python3 -m unittest tests.test_ask_user_runtime -v
|
||||||
|
python3 -m unittest tests.test_team_runtime -v
|
||||||
python3 -m unittest tests.test_tokenizer_runtime -v
|
python3 -m unittest tests.test_tokenizer_runtime -v
|
||||||
python3 -m unittest tests.test_extended_tools -v
|
python3 -m unittest tests.test_extended_tools -v
|
||||||
python3 -m unittest tests.test_porting_workspace -v
|
python3 -m unittest tests.test_porting_workspace -v
|
||||||
@@ -191,6 +195,7 @@ mkdir -p ./test_cases_budget
|
|||||||
mkdir -p ./test_cases_plugins/plugins/demo
|
mkdir -p ./test_cases_plugins/plugins/demo
|
||||||
mkdir -p ./test_cases_mcp
|
mkdir -p ./test_cases_mcp
|
||||||
mkdir -p ./test_cases_tasks
|
mkdir -p ./test_cases_tasks
|
||||||
|
mkdir -p ./test_cases_notebooks
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.1 Config fixtures
|
### 4.1 Config fixtures
|
||||||
@@ -243,6 +248,67 @@ cat > ./test_cases/.claw-account.json <<'EOF'
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 4.2b Ask-user fixtures
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > ./test_cases/.claw-ask-user.json <<'EOF'
|
||||||
|
{
|
||||||
|
"answers": [
|
||||||
|
{
|
||||||
|
"question": "Approve deploy?",
|
||||||
|
"answer": "yes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Choose rollout mode",
|
||||||
|
"answer": "safe"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2c Team fixtures
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > ./test_cases/.claw-teams.json <<'EOF'
|
||||||
|
{
|
||||||
|
"teams": [
|
||||||
|
{
|
||||||
|
"name": "reviewers",
|
||||||
|
"description": "Code review group",
|
||||||
|
"members": ["alice", "bob"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release",
|
||||||
|
"description": "Release coordination group",
|
||||||
|
"members": ["ops", "qa"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2d Notebook fixture
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > ./test_cases_notebooks/demo.ipynb <<'EOF'
|
||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"metadata": {},
|
||||||
|
"source": ["print(1)\n"],
|
||||||
|
"outputs": [],
|
||||||
|
"execution_count": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
### 4.3 Remote fixtures
|
### 4.3 Remote fixtures
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -528,11 +594,13 @@ python3 -m src.main agent "/settings" --cwd ./test_cases
|
|||||||
python3 -m src.main agent "/account" --cwd ./test_cases
|
python3 -m src.main agent "/account" --cwd ./test_cases
|
||||||
python3 -m src.main agent "/account profiles" --cwd ./test_cases
|
python3 -m src.main agent "/account profiles" --cwd ./test_cases
|
||||||
python3 -m src.main agent "/account profile local" --cwd ./test_cases
|
python3 -m src.main agent "/account profile local" --cwd ./test_cases
|
||||||
|
python3 -m src.main agent "/ask" --cwd ./test_cases
|
||||||
|
python3 -m src.main agent "/ask history" --cwd ./test_cases
|
||||||
python3 -m src.main agent "/login local" --cwd ./test_cases
|
python3 -m src.main agent "/login local" --cwd ./test_cases
|
||||||
python3 -m src.main agent "/logout" --cwd ./test_cases
|
python3 -m src.main agent "/logout" --cwd ./test_cases
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.3 Remote, search, task, and plan slash commands
|
### 5.3 Remote, search, team, task, and plan slash commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main agent "/remote" --cwd ./test_cases
|
python3 -m src.main agent "/remote" --cwd ./test_cases
|
||||||
@@ -549,6 +617,10 @@ python3 -m src.main agent "/search providers" --cwd ./test_cases
|
|||||||
python3 -m src.main agent "/search provider local-search" --cwd ./test_cases
|
python3 -m src.main agent "/search provider local-search" --cwd ./test_cases
|
||||||
python3 -m src.main agent "/search use local-search" --cwd ./test_cases
|
python3 -m src.main agent "/search use local-search" --cwd ./test_cases
|
||||||
python3 -m src.main agent "/search python argparse tutorial" --cwd ./test_cases
|
python3 -m src.main agent "/search python argparse tutorial" --cwd ./test_cases
|
||||||
|
python3 -m src.main agent "/teams" --cwd ./test_cases
|
||||||
|
python3 -m src.main agent "/team reviewers" --cwd ./test_cases
|
||||||
|
python3 -m src.main agent "/messages" --cwd ./test_cases
|
||||||
|
python3 -m src.main agent "/messages reviewers" --cwd ./test_cases
|
||||||
python3 -m src.main agent "/plan" --cwd ./test_cases_tasks
|
python3 -m src.main agent "/plan" --cwd ./test_cases_tasks
|
||||||
python3 -m src.main agent "/planner" --cwd ./test_cases_tasks
|
python3 -m src.main agent "/planner" --cwd ./test_cases_tasks
|
||||||
python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks
|
python3 -m src.main agent "/tasks" --cwd ./test_cases_tasks
|
||||||
@@ -1156,6 +1228,31 @@ python3 -m src.main agent \
|
|||||||
--show-transcript
|
--show-transcript
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 14A. Ask-user Runtime
|
||||||
|
|
||||||
|
### 14A.1 CLI status and history
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m src.main ask-status --cwd ./test_cases
|
||||||
|
python3 -m src.main ask-history --cwd ./test_cases
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14A.2 Slash commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m src.main agent "/ask" --cwd ./test_cases
|
||||||
|
python3 -m src.main agent "/ask history" --cwd ./test_cases
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14A.3 Real tool loop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m src.main agent \
|
||||||
|
"Use ask_user_question to answer 'Approve deploy?' and then summarize the decision." \
|
||||||
|
--cwd ./test_cases \
|
||||||
|
--show-transcript
|
||||||
|
```
|
||||||
|
|
||||||
## 15. Search Runtime And Real Web Search
|
## 15. Search Runtime And Real Web Search
|
||||||
|
|
||||||
### 15.1 Provider status and activation
|
### 15.1 Provider status and activation
|
||||||
@@ -1303,6 +1400,56 @@ python3 -m src.main agent-context-raw --cwd ./test_cases_tasks
|
|||||||
python3 -m src.main agent-prompt --cwd ./test_cases_tasks
|
python3 -m src.main agent-prompt --cwd ./test_cases_tasks
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 18A. Team Runtime
|
||||||
|
|
||||||
|
### 18A.1 CLI status and inspection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m src.main team-status --cwd ./test_cases
|
||||||
|
python3 -m src.main team-list --cwd ./test_cases
|
||||||
|
python3 -m src.main team-get reviewers --cwd ./test_cases
|
||||||
|
python3 -m src.main team-messages --cwd ./test_cases
|
||||||
|
```
|
||||||
|
|
||||||
|
### 18A.2 Create and delete teams
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m src.main team-create docs --member alice --member bob --cwd ./test_cases
|
||||||
|
python3 -m src.main team-list --cwd ./test_cases
|
||||||
|
python3 -m src.main team-delete docs --cwd ./test_cases
|
||||||
|
```
|
||||||
|
|
||||||
|
### 18A.3 Slash commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m src.main agent "/teams" --cwd ./test_cases
|
||||||
|
python3 -m src.main agent "/team reviewers" --cwd ./test_cases
|
||||||
|
python3 -m src.main agent "/messages" --cwd ./test_cases
|
||||||
|
python3 -m src.main agent "/messages reviewers" --cwd ./test_cases
|
||||||
|
```
|
||||||
|
|
||||||
|
### 18A.4 Real tool loop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m src.main agent \
|
||||||
|
"Create a local team called docs with members alice and bob, send a message to that team asking for notebook review, then show the team messages." \
|
||||||
|
--cwd ./test_cases \
|
||||||
|
--allow-write \
|
||||||
|
--show-transcript
|
||||||
|
```
|
||||||
|
|
||||||
|
## 18B. Notebook Edit Tool
|
||||||
|
|
||||||
|
### 18B.1 Direct notebook edit through the agent loop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m src.main agent \
|
||||||
|
"Use notebook_edit to update demo.ipynb cell 0 so it prints 2, then read back the notebook file and summarize the change." \
|
||||||
|
--cwd ./test_cases_notebooks \
|
||||||
|
--allow-write \
|
||||||
|
--show-transcript
|
||||||
|
```
|
||||||
|
|
||||||
### 18.2 Create and inspect tasks
|
### 18.2 Create and inspect tasks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ def build_verifier_exec_command(
|
|||||||
task_dir: Path,
|
task_dir: Path,
|
||||||
verifier_logs_dir: Path,
|
verifier_logs_dir: Path,
|
||||||
env: dict[str, str],
|
env: dict[str, str],
|
||||||
|
fakeroot: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
binds = [
|
binds = [
|
||||||
f"{workspace_dir}:{task.workdir}:rw",
|
f"{workspace_dir}:{task.workdir}:rw",
|
||||||
@@ -278,18 +279,35 @@ def build_verifier_exec_command(
|
|||||||
f"{verifier_logs_dir}:/logs/verifier:rw",
|
f"{verifier_logs_dir}:/logs/verifier:rw",
|
||||||
]
|
]
|
||||||
bind_flags = " ".join(f"--bind {shlex.quote(spec)}" for spec in binds)
|
bind_flags = " ".join(f"--bind {shlex.quote(spec)}" for spec in binds)
|
||||||
|
|
||||||
|
# When using fakeroot (no-sudo HPC), inject env vars needed for apt-get
|
||||||
|
# and SSL inside the container.
|
||||||
|
if fakeroot:
|
||||||
|
fakeroot_env = {
|
||||||
|
"TMPDIR": "/tmp",
|
||||||
|
"DEBIAN_FRONTEND": "noninteractive",
|
||||||
|
"CURL_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
|
||||||
|
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
|
||||||
|
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
|
||||||
|
}
|
||||||
|
env = {**fakeroot_env, **env}
|
||||||
|
|
||||||
env_flags = " ".join(
|
env_flags = " ".join(
|
||||||
f"--env {shlex.quote(key)}={shlex.quote(value)}"
|
f"--env {shlex.quote(key)}={shlex.quote(value)}"
|
||||||
for key, value in sorted(env.items())
|
for key, value in sorted(env.items())
|
||||||
)
|
)
|
||||||
inner = (
|
inner = (
|
||||||
"set -euo pipefail; "
|
"set -euo pipefail; "
|
||||||
"chmod +x /tests/test.sh; "
|
"chmod +x /tests/test.sh 2>/dev/null || true; "
|
||||||
f"cd {shlex.quote(task.workdir)}; "
|
f"cd {shlex.quote(task.workdir)}; "
|
||||||
"/tests/test.sh > /logs/verifier/test-stdout.txt 2> /logs/verifier/test-stderr.txt"
|
"bash /tests/test.sh > /logs/verifier/test-stdout.txt 2> /logs/verifier/test-stderr.txt"
|
||||||
)
|
)
|
||||||
|
# --fakeroot: simulate root inside the container (for apt-get etc.)
|
||||||
|
# --writable-tmpfs: in-memory overlay so packages can be installed
|
||||||
|
# --contain: prevent host dirs from leaking into container
|
||||||
|
fakeroot_flags = "--fakeroot --writable-tmpfs --contain " if fakeroot else ""
|
||||||
return (
|
return (
|
||||||
f"apptainer exec --cleanenv {bind_flags} {env_flags} "
|
f"apptainer exec --cleanenv {fakeroot_flags}{bind_flags} {env_flags} "
|
||||||
f"--cwd {shlex.quote(task.workdir)} {shlex.quote(str(image_path))} "
|
f"--cwd {shlex.quote(task.workdir)} {shlex.quote(str(image_path))} "
|
||||||
f"bash -lc {shlex.quote(inner)}"
|
f"bash -lc {shlex.quote(inner)}"
|
||||||
)
|
)
|
||||||
@@ -304,6 +322,7 @@ def run_trial(
|
|||||||
force_pull: bool,
|
force_pull: bool,
|
||||||
keep_images: bool,
|
keep_images: bool,
|
||||||
timeout_multiplier: float,
|
timeout_multiplier: float,
|
||||||
|
fakeroot: bool = False,
|
||||||
) -> LocalTrialResult:
|
) -> LocalTrialResult:
|
||||||
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
||||||
trial_dir = jobs_dir / f"{timestamp}_{safe_name(task.short_name)}"
|
trial_dir = jobs_dir / f"{timestamp}_{safe_name(task.short_name)}"
|
||||||
@@ -363,6 +382,7 @@ def run_trial(
|
|||||||
task_dir=task.task_dir,
|
task_dir=task.task_dir,
|
||||||
verifier_logs_dir=verifier_logs_dir,
|
verifier_logs_dir=verifier_logs_dir,
|
||||||
env=verifier_env,
|
env=verifier_env,
|
||||||
|
fakeroot=fakeroot,
|
||||||
)
|
)
|
||||||
|
|
||||||
agent_timeout = (task.agent_timeout_sec or 1800.0) * timeout_multiplier
|
agent_timeout = (task.agent_timeout_sec or 1800.0) * timeout_multiplier
|
||||||
@@ -447,6 +467,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument("--timeout-multiplier", type=float, default=1.0, help="Multiplier applied to agent and verifier timeouts.")
|
parser.add_argument("--timeout-multiplier", type=float, default=1.0, help="Multiplier applied to agent and verifier timeouts.")
|
||||||
parser.add_argument("--output", type=Path, help="Optional JSON file for the run summary.")
|
parser.add_argument("--output", type=Path, help="Optional JSON file for the run summary.")
|
||||||
parser.add_argument("--list", action="store_true", help="List discovered tasks and exit.")
|
parser.add_argument("--list", action="store_true", help="List discovered tasks and exit.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--fakeroot",
|
||||||
|
action="store_true",
|
||||||
|
help="Use Apptainer --fakeroot + --writable-tmpfs for the verifier container. "
|
||||||
|
"Required on HPC systems without sudo where test.sh scripts need apt-get.",
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -481,6 +507,8 @@ def main() -> None:
|
|||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print(f" Tasks: {len(tasks)}")
|
print(f" Tasks: {len(tasks)}")
|
||||||
print(f" Jobs dir: {args.jobs_dir}")
|
print(f" Jobs dir: {args.jobs_dir}")
|
||||||
|
if args.fakeroot:
|
||||||
|
print(" Fakeroot: enabled (no-sudo HPC mode)")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -494,6 +522,7 @@ def main() -> None:
|
|||||||
force_pull=args.force_pull,
|
force_pull=args.force_pull,
|
||||||
keep_images=args.keep_images,
|
keep_images=args.keep_images,
|
||||||
timeout_multiplier=args.timeout_multiplier,
|
timeout_multiplier=args.timeout_multiplier,
|
||||||
|
fakeroot=args.fakeroot,
|
||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
icon = "PASS ✅" if result.passed else ("SKIP ⚪" if result.status == "skipped" else "FAIL ❌")
|
icon = "PASS ✅" if result.passed else ("SKIP ⚪" if result.status == "skipped" else "FAIL ❌")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Python porting workspace for the Claude Code rewrite effort."""
|
"""Python porting workspace for the Claude Code rewrite effort."""
|
||||||
|
|
||||||
from .account_runtime import AccountRuntime, AccountProfile, AccountSessionState, AccountStatusReport
|
from .account_runtime import AccountRuntime, AccountProfile, AccountSessionState, AccountStatusReport
|
||||||
|
from .ask_user_runtime import AskUserRuntime, AskUserResponse, QueuedUserAnswer
|
||||||
from .agent_context import (
|
from .agent_context import (
|
||||||
AgentContextSnapshot,
|
AgentContextSnapshot,
|
||||||
build_context_snapshot,
|
build_context_snapshot,
|
||||||
@@ -29,6 +30,7 @@ from .session_store import StoredSession, load_session, save_session
|
|||||||
from .system_init import build_system_init_message
|
from .system_init import build_system_init_message
|
||||||
from .task import PortingTask
|
from .task import PortingTask
|
||||||
from .task_runtime import TaskRuntime
|
from .task_runtime import TaskRuntime
|
||||||
|
from .team_runtime import TeamDefinition, TeamMessage, TeamRuntime
|
||||||
from .tokenizer_runtime import TokenCounterInfo, clear_token_counter_cache, count_tokens, describe_token_counter
|
from .tokenizer_runtime import TokenCounterInfo, clear_token_counter_cache, count_tokens, describe_token_counter
|
||||||
from .tools import PORTED_TOOLS, build_tool_backlog
|
from .tools import PORTED_TOOLS, build_tool_backlog
|
||||||
|
|
||||||
@@ -42,6 +44,8 @@ __all__ = [
|
|||||||
'AccountRuntime',
|
'AccountRuntime',
|
||||||
'AccountSessionState',
|
'AccountSessionState',
|
||||||
'AccountStatusReport',
|
'AccountStatusReport',
|
||||||
|
'AskUserResponse',
|
||||||
|
'AskUserRuntime',
|
||||||
'AgentMessage',
|
'AgentMessage',
|
||||||
'AgentSessionState',
|
'AgentSessionState',
|
||||||
'BackgroundSessionRuntime',
|
'BackgroundSessionRuntime',
|
||||||
@@ -60,6 +64,7 @@ __all__ = [
|
|||||||
'PortRuntime',
|
'PortRuntime',
|
||||||
'PluginRuntime',
|
'PluginRuntime',
|
||||||
'PortingTask',
|
'PortingTask',
|
||||||
|
'QueuedUserAnswer',
|
||||||
'QueryEnginePort',
|
'QueryEnginePort',
|
||||||
'RuntimeSession',
|
'RuntimeSession',
|
||||||
'SearchProviderProfile',
|
'SearchProviderProfile',
|
||||||
@@ -68,6 +73,9 @@ __all__ = [
|
|||||||
'SearchStatusReport',
|
'SearchStatusReport',
|
||||||
'StoredSession',
|
'StoredSession',
|
||||||
'TaskRuntime',
|
'TaskRuntime',
|
||||||
|
'TeamDefinition',
|
||||||
|
'TeamMessage',
|
||||||
|
'TeamRuntime',
|
||||||
'TokenCounterInfo',
|
'TokenCounterInfo',
|
||||||
'TurnResult',
|
'TurnResult',
|
||||||
'PORTED_COMMANDS',
|
'PORTED_COMMANDS',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .agent_plugin_cache import load_plugin_cache_summary
|
from .agent_plugin_cache import load_plugin_cache_summary
|
||||||
from .account_runtime import AccountRuntime
|
from .account_runtime import AccountRuntime
|
||||||
|
from .ask_user_runtime import AskUserRuntime
|
||||||
from .config_runtime import ConfigRuntime
|
from .config_runtime import ConfigRuntime
|
||||||
from .hook_policy import HookPolicyRuntime
|
from .hook_policy import HookPolicyRuntime
|
||||||
from .mcp_runtime import MCPRuntime
|
from .mcp_runtime import MCPRuntime
|
||||||
@@ -18,6 +19,7 @@ from .plugin_runtime import PluginRuntime
|
|||||||
from .remote_runtime import RemoteRuntime
|
from .remote_runtime import RemoteRuntime
|
||||||
from .search_runtime import SearchRuntime
|
from .search_runtime import SearchRuntime
|
||||||
from .task_runtime import TaskRuntime
|
from .task_runtime import TaskRuntime
|
||||||
|
from .team_runtime import TeamRuntime
|
||||||
from .agent_types import AgentRuntimeConfig
|
from .agent_types import AgentRuntimeConfig
|
||||||
|
|
||||||
MAX_STATUS_CHARS = 2000
|
MAX_STATUS_CHARS = 2000
|
||||||
@@ -237,6 +239,9 @@ def _get_user_context_cached(
|
|||||||
account_runtime = AccountRuntime.from_workspace(Path(cwd), additional_working_directories)
|
account_runtime = AccountRuntime.from_workspace(Path(cwd), additional_working_directories)
|
||||||
if account_runtime.has_account_state():
|
if account_runtime.has_account_state():
|
||||||
context['accountRuntime'] = account_runtime.render_summary()
|
context['accountRuntime'] = account_runtime.render_summary()
|
||||||
|
ask_user_runtime = AskUserRuntime.from_workspace(Path(cwd), additional_working_directories)
|
||||||
|
if ask_user_runtime.has_state():
|
||||||
|
context['askUserRuntime'] = ask_user_runtime.render_summary()
|
||||||
config_runtime = ConfigRuntime.from_workspace(Path(cwd))
|
config_runtime = ConfigRuntime.from_workspace(Path(cwd))
|
||||||
if config_runtime.has_config():
|
if config_runtime.has_config():
|
||||||
context['configRuntime'] = config_runtime.render_summary()
|
context['configRuntime'] = config_runtime.render_summary()
|
||||||
@@ -246,6 +251,9 @@ def _get_user_context_cached(
|
|||||||
task_runtime = TaskRuntime.from_workspace(Path(cwd))
|
task_runtime = TaskRuntime.from_workspace(Path(cwd))
|
||||||
if task_runtime.tasks:
|
if task_runtime.tasks:
|
||||||
context['taskRuntime'] = task_runtime.render_summary()
|
context['taskRuntime'] = task_runtime.render_summary()
|
||||||
|
team_runtime = TeamRuntime.from_workspace(Path(cwd), additional_working_directories)
|
||||||
|
if team_runtime.has_team_state():
|
||||||
|
context['teamRuntime'] = team_runtime.render_summary()
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -95,9 +95,11 @@ def build_system_prompt_parts(
|
|||||||
get_remote_guidance_section(prompt_context),
|
get_remote_guidance_section(prompt_context),
|
||||||
get_search_guidance_section(prompt_context),
|
get_search_guidance_section(prompt_context),
|
||||||
get_account_guidance_section(prompt_context),
|
get_account_guidance_section(prompt_context),
|
||||||
|
get_ask_user_guidance_section(prompt_context),
|
||||||
get_config_guidance_section(prompt_context),
|
get_config_guidance_section(prompt_context),
|
||||||
get_plan_guidance_section(prompt_context),
|
get_plan_guidance_section(prompt_context),
|
||||||
get_task_guidance_section(prompt_context),
|
get_task_guidance_section(prompt_context),
|
||||||
|
get_team_guidance_section(prompt_context),
|
||||||
get_hook_policy_guidance_section(prompt_context),
|
get_hook_policy_guidance_section(prompt_context),
|
||||||
get_tone_and_style_section(),
|
get_tone_and_style_section(),
|
||||||
get_output_efficiency_section(),
|
get_output_efficiency_section(),
|
||||||
@@ -277,6 +279,18 @@ def get_account_guidance_section(prompt_context: PromptContext) -> str:
|
|||||||
return '\n'.join(['# Account', *prepend_bullets(items)])
|
return '\n'.join(['# Account', *prepend_bullets(items)])
|
||||||
|
|
||||||
|
|
||||||
|
def get_ask_user_guidance_section(prompt_context: PromptContext) -> str:
|
||||||
|
ask_user_runtime = prompt_context.user_context.get('askUserRuntime')
|
||||||
|
if not ask_user_runtime:
|
||||||
|
return ''
|
||||||
|
items = [
|
||||||
|
'A local ask-user runtime may be available with queued answers or optional interactive prompting.',
|
||||||
|
'Use ask_user_question when you genuinely need a user decision or clarification that should not be guessed.',
|
||||||
|
'If ask_user_question reports that no queued answer is available, explain the limitation or ask the human user directly outside the tool loop.',
|
||||||
|
]
|
||||||
|
return '\n'.join(['# Ask User', *prepend_bullets(items)])
|
||||||
|
|
||||||
|
|
||||||
def get_config_guidance_section(prompt_context: PromptContext) -> str:
|
def get_config_guidance_section(prompt_context: PromptContext) -> str:
|
||||||
config_runtime = prompt_context.user_context.get('configRuntime')
|
config_runtime = prompt_context.user_context.get('configRuntime')
|
||||||
if not config_runtime:
|
if not config_runtime:
|
||||||
@@ -302,6 +316,18 @@ def get_task_guidance_section(prompt_context: PromptContext) -> str:
|
|||||||
return '\n'.join(['# Tasks', *prepend_bullets(items)])
|
return '\n'.join(['# Tasks', *prepend_bullets(items)])
|
||||||
|
|
||||||
|
|
||||||
|
def get_team_guidance_section(prompt_context: PromptContext) -> str:
|
||||||
|
team_runtime = prompt_context.user_context.get('teamRuntime')
|
||||||
|
if not team_runtime:
|
||||||
|
return ''
|
||||||
|
items = [
|
||||||
|
'A local collaboration team runtime may be available with persisted teams and message history.',
|
||||||
|
'Use the team tools when the task needs local team state, simple collaboration metadata, or persisted teammate messages.',
|
||||||
|
'Use send_message to record a concrete handoff or note to a team instead of burying it in free-form assistant text.',
|
||||||
|
]
|
||||||
|
return '\n'.join(['# Teams', *prepend_bullets(items)])
|
||||||
|
|
||||||
|
|
||||||
def get_plan_guidance_section(prompt_context: PromptContext) -> str:
|
def get_plan_guidance_section(prompt_context: PromptContext) -> str:
|
||||||
plan_runtime = prompt_context.user_context.get('planRuntime')
|
plan_runtime = prompt_context.user_context.get('planRuntime')
|
||||||
if not plan_runtime:
|
if not plan_runtime:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .agent_manager import AgentManager
|
|||||||
from .agent_context import clear_context_caches
|
from .agent_context import clear_context_caches
|
||||||
from .agent_context import render_context_report as render_agent_context_report
|
from .agent_context import render_context_report as render_agent_context_report
|
||||||
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
|
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
|
||||||
|
from .ask_user_runtime import AskUserRuntime
|
||||||
from .config_runtime import ConfigRuntime
|
from .config_runtime import ConfigRuntime
|
||||||
from .hook_policy import HookPolicyRuntime
|
from .hook_policy import HookPolicyRuntime
|
||||||
from .mcp_runtime import MCPRuntime
|
from .mcp_runtime import MCPRuntime
|
||||||
@@ -48,6 +49,7 @@ from .plugin_runtime import PluginRuntime
|
|||||||
from .remote_runtime import RemoteRuntime
|
from .remote_runtime import RemoteRuntime
|
||||||
from .search_runtime import SearchRuntime
|
from .search_runtime import SearchRuntime
|
||||||
from .task_runtime import TaskRuntime
|
from .task_runtime import TaskRuntime
|
||||||
|
from .team_runtime import TeamRuntime
|
||||||
from .tokenizer_runtime import describe_token_counter
|
from .tokenizer_runtime import describe_token_counter
|
||||||
from .session_store import (
|
from .session_store import (
|
||||||
StoredAgentSession,
|
StoredAgentSession,
|
||||||
@@ -84,9 +86,11 @@ class LocalCodingAgent:
|
|||||||
remote_runtime: RemoteRuntime | None = None
|
remote_runtime: RemoteRuntime | None = None
|
||||||
search_runtime: SearchRuntime | None = None
|
search_runtime: SearchRuntime | None = None
|
||||||
account_runtime: AccountRuntime | None = None
|
account_runtime: AccountRuntime | None = None
|
||||||
|
ask_user_runtime: AskUserRuntime | None = None
|
||||||
config_runtime: ConfigRuntime | None = None
|
config_runtime: ConfigRuntime | None = None
|
||||||
plan_runtime: PlanRuntime | None = None
|
plan_runtime: PlanRuntime | None = None
|
||||||
task_runtime: TaskRuntime | None = None
|
task_runtime: TaskRuntime | None = None
|
||||||
|
team_runtime: TeamRuntime | None = None
|
||||||
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
|
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
|
||||||
last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False)
|
last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False)
|
||||||
active_session_id: str | None = field(default=None, init=False, repr=False)
|
active_session_id: str | None = field(default=None, init=False, repr=False)
|
||||||
@@ -129,12 +133,22 @@ class LocalCodingAgent:
|
|||||||
self.runtime_config.cwd,
|
self.runtime_config.cwd,
|
||||||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||||||
)
|
)
|
||||||
|
if self.ask_user_runtime is None:
|
||||||
|
self.ask_user_runtime = AskUserRuntime.from_workspace(
|
||||||
|
self.runtime_config.cwd,
|
||||||
|
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||||||
|
)
|
||||||
if self.config_runtime is None:
|
if self.config_runtime is None:
|
||||||
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
|
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
|
||||||
if self.plan_runtime is None:
|
if self.plan_runtime is None:
|
||||||
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
|
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
|
||||||
if self.task_runtime is None:
|
if self.task_runtime is None:
|
||||||
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
|
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
|
||||||
|
if self.team_runtime is None:
|
||||||
|
self.team_runtime = TeamRuntime.from_workspace(
|
||||||
|
self.runtime_config.cwd,
|
||||||
|
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||||||
|
)
|
||||||
self.runtime_config = self._apply_hook_policy_budget_overrides(self.runtime_config)
|
self.runtime_config = self._apply_hook_policy_budget_overrides(self.runtime_config)
|
||||||
registry = dict(self.tool_registry)
|
registry = dict(self.tool_registry)
|
||||||
plugin_tools = self.plugin_runtime.register_tool_aliases(registry)
|
plugin_tools = self.plugin_runtime.register_tool_aliases(registry)
|
||||||
@@ -155,11 +169,13 @@ class LocalCodingAgent:
|
|||||||
),
|
),
|
||||||
search_runtime=self.search_runtime,
|
search_runtime=self.search_runtime,
|
||||||
account_runtime=self.account_runtime,
|
account_runtime=self.account_runtime,
|
||||||
|
ask_user_runtime=self.ask_user_runtime,
|
||||||
config_runtime=self.config_runtime,
|
config_runtime=self.config_runtime,
|
||||||
mcp_runtime=self.mcp_runtime,
|
mcp_runtime=self.mcp_runtime,
|
||||||
remote_runtime=self.remote_runtime,
|
remote_runtime=self.remote_runtime,
|
||||||
plan_runtime=self.plan_runtime,
|
plan_runtime=self.plan_runtime,
|
||||||
task_runtime=self.task_runtime,
|
task_runtime=self.task_runtime,
|
||||||
|
team_runtime=self.team_runtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_model(self, model: str) -> None:
|
def set_model(self, model: str) -> None:
|
||||||
@@ -3063,6 +3079,37 @@ class LocalCodingAgent:
|
|||||||
return '# Task\n\nNo local task runtime is available.'
|
return '# Task\n\nNo local task runtime is available.'
|
||||||
return self.task_runtime.render_task(task_id)
|
return self.task_runtime.render_task(task_id)
|
||||||
|
|
||||||
|
def render_ask_user_report(self) -> str:
|
||||||
|
if self.ask_user_runtime is None:
|
||||||
|
return '# Ask User\n\nNo local ask-user runtime is available.'
|
||||||
|
return '\n'.join(['# Ask User', '', self.ask_user_runtime.render_summary()])
|
||||||
|
|
||||||
|
def render_ask_user_history_report(self) -> str:
|
||||||
|
if self.ask_user_runtime is None:
|
||||||
|
return '# Ask User History\n\nNo local ask-user runtime is available.'
|
||||||
|
return self.ask_user_runtime.render_history()
|
||||||
|
|
||||||
|
def render_teams_report(self, query: str | None = None) -> str:
|
||||||
|
if self.team_runtime is None:
|
||||||
|
return '# Teams\n\nNo local team runtime is available.'
|
||||||
|
return self.team_runtime.render_teams_index(query=query)
|
||||||
|
|
||||||
|
def render_team_report(self, team_name: str) -> str:
|
||||||
|
if self.team_runtime is None:
|
||||||
|
return '# Team\n\nNo local team runtime is available.'
|
||||||
|
try:
|
||||||
|
return self.team_runtime.render_team(team_name)
|
||||||
|
except KeyError:
|
||||||
|
return f'# Team\n\nUnknown team: {team_name}'
|
||||||
|
|
||||||
|
def render_team_messages_report(self, team_name: str | None = None) -> str:
|
||||||
|
if self.team_runtime is None:
|
||||||
|
return '# Team Messages\n\nNo local team runtime is available.'
|
||||||
|
try:
|
||||||
|
return self.team_runtime.render_messages(team_name=team_name)
|
||||||
|
except KeyError:
|
||||||
|
return f'# Team Messages\n\nUnknown team: {team_name}'
|
||||||
|
|
||||||
def render_hook_policy_report(self) -> str:
|
def render_hook_policy_report(self) -> str:
|
||||||
if self.hook_policy_runtime is None:
|
if self.hook_policy_runtime is None:
|
||||||
return '# Hook Policy\n\nNo local hook or policy manifests discovered.'
|
return '# Hook Policy\n\nNo local hook or policy manifests discovered.'
|
||||||
@@ -3131,6 +3178,9 @@ class LocalCodingAgent:
|
|||||||
lines.append(
|
lines.append(
|
||||||
f'- Active account: {session.provider} -> {session.identity}'
|
f'- Active account: {session.provider} -> {session.identity}'
|
||||||
)
|
)
|
||||||
|
if self.ask_user_runtime is not None and self.ask_user_runtime.has_state():
|
||||||
|
lines.append(f'- Ask-user queued answers: {len(self.ask_user_runtime.queued_answers)}')
|
||||||
|
lines.append(f'- Ask-user history: {len(self.ask_user_runtime.history)}')
|
||||||
if self.config_runtime is not None and self.config_runtime.has_config():
|
if self.config_runtime is not None and self.config_runtime.has_config():
|
||||||
lines.append(f'- Config sources: {len(self.config_runtime.sources)}')
|
lines.append(f'- Config sources: {len(self.config_runtime.sources)}')
|
||||||
lines.append(
|
lines.append(
|
||||||
@@ -3140,6 +3190,9 @@ class LocalCodingAgent:
|
|||||||
lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}')
|
lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}')
|
||||||
if self.task_runtime is not None and self.task_runtime.tasks:
|
if self.task_runtime is not None and self.task_runtime.tasks:
|
||||||
lines.append(f'- Local tasks: {len(self.task_runtime.tasks)}')
|
lines.append(f'- Local tasks: {len(self.task_runtime.tasks)}')
|
||||||
|
if self.team_runtime is not None and self.team_runtime.has_team_state():
|
||||||
|
lines.append(f'- Local teams: {len(self.team_runtime.teams)}')
|
||||||
|
lines.append(f'- Team messages: {len(self.team_runtime.messages)}')
|
||||||
if self.last_session_path is not None:
|
if self.last_session_path is not None:
|
||||||
lines.append(f'- Session path: {self.last_session_path}')
|
lines.append(f'- Session path: {self.last_session_path}')
|
||||||
if self.last_run_result is not None:
|
if self.last_run_result is not None:
|
||||||
@@ -3198,6 +3251,10 @@ class LocalCodingAgent:
|
|||||||
'account_login',
|
'account_login',
|
||||||
'account_logout',
|
'account_logout',
|
||||||
'config_set',
|
'config_set',
|
||||||
|
'ask_user_question',
|
||||||
|
'team_create',
|
||||||
|
'team_delete',
|
||||||
|
'send_message',
|
||||||
}
|
}
|
||||||
if tool_name not in refresh_tool_names:
|
if tool_name not in refresh_tool_names:
|
||||||
return
|
return
|
||||||
@@ -3220,21 +3277,33 @@ class LocalCodingAgent:
|
|||||||
self.runtime_config.cwd,
|
self.runtime_config.cwd,
|
||||||
additional_working_directories=additional_dirs,
|
additional_working_directories=additional_dirs,
|
||||||
)
|
)
|
||||||
|
if tool_name == 'ask_user_question':
|
||||||
|
self.ask_user_runtime = AskUserRuntime.from_workspace(
|
||||||
|
self.runtime_config.cwd,
|
||||||
|
additional_working_directories=additional_dirs,
|
||||||
|
)
|
||||||
if tool_name == 'config_set':
|
if tool_name == 'config_set':
|
||||||
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
|
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
|
||||||
if tool_name.startswith('task_') or tool_name == 'todo_write':
|
if tool_name.startswith('task_') or tool_name == 'todo_write':
|
||||||
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
|
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
|
||||||
if tool_name.startswith('plan_') or tool_name == 'update_plan':
|
if tool_name.startswith('plan_') or tool_name == 'update_plan':
|
||||||
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
|
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
|
||||||
|
if tool_name.startswith('team_') or tool_name == 'send_message':
|
||||||
|
self.team_runtime = TeamRuntime.from_workspace(
|
||||||
|
self.runtime_config.cwd,
|
||||||
|
additional_working_directories=additional_dirs,
|
||||||
|
)
|
||||||
self.tool_context = replace(
|
self.tool_context = replace(
|
||||||
self.tool_context,
|
self.tool_context,
|
||||||
tool_registry=self.tool_registry,
|
tool_registry=self.tool_registry,
|
||||||
search_runtime=self.search_runtime,
|
search_runtime=self.search_runtime,
|
||||||
account_runtime=self.account_runtime,
|
account_runtime=self.account_runtime,
|
||||||
|
ask_user_runtime=self.ask_user_runtime,
|
||||||
config_runtime=self.config_runtime,
|
config_runtime=self.config_runtime,
|
||||||
remote_runtime=self.remote_runtime,
|
remote_runtime=self.remote_runtime,
|
||||||
plan_runtime=self.plan_runtime,
|
plan_runtime=self.plan_runtime,
|
||||||
task_runtime=self.task_runtime,
|
task_runtime=self.task_runtime,
|
||||||
|
team_runtime=self.team_runtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str:
|
def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str:
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
|
|||||||
description='Show local account runtime status or configured account profiles.',
|
description='Show local account runtime status or configured account profiles.',
|
||||||
handler=_handle_account,
|
handler=_handle_account,
|
||||||
),
|
),
|
||||||
|
SlashCommandSpec(
|
||||||
|
names=('ask',),
|
||||||
|
description='Show local ask-user runtime status or ask-user history.',
|
||||||
|
handler=_handle_ask,
|
||||||
|
),
|
||||||
SlashCommandSpec(
|
SlashCommandSpec(
|
||||||
names=('login',),
|
names=('login',),
|
||||||
description='Activate a local account profile or ephemeral identity.',
|
description='Activate a local account profile or ephemeral identity.',
|
||||||
@@ -189,6 +194,21 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
|
|||||||
description='Show the local runtime task list, optionally filtered by status.',
|
description='Show the local runtime task list, optionally filtered by status.',
|
||||||
handler=_handle_tasks,
|
handler=_handle_tasks,
|
||||||
),
|
),
|
||||||
|
SlashCommandSpec(
|
||||||
|
names=('teams',),
|
||||||
|
description='List the locally configured collaboration teams.',
|
||||||
|
handler=_handle_teams,
|
||||||
|
),
|
||||||
|
SlashCommandSpec(
|
||||||
|
names=('team',),
|
||||||
|
description='Show one local collaboration team by name.',
|
||||||
|
handler=_handle_team,
|
||||||
|
),
|
||||||
|
SlashCommandSpec(
|
||||||
|
names=('messages',),
|
||||||
|
description='Show recorded collaboration messages for all teams or one team.',
|
||||||
|
handler=_handle_messages,
|
||||||
|
),
|
||||||
SlashCommandSpec(
|
SlashCommandSpec(
|
||||||
names=('task-next', 'next-task'),
|
names=('task-next', 'next-task'),
|
||||||
description='Show the next actionable tasks from the local runtime task list.',
|
description='Show the next actionable tasks from the local runtime task list.',
|
||||||
@@ -343,6 +363,15 @@ def _handle_account(agent: 'LocalCodingAgent', args: str, input_text: str) -> Sl
|
|||||||
return _local_result(input_text, 'Usage: /account [profiles|profile <name>]')
|
return _local_result(input_text, 'Usage: /account [profiles|profile <name>]')
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_ask(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||||
|
command = args.strip()
|
||||||
|
if not command:
|
||||||
|
return _local_result(input_text, agent.render_ask_user_report())
|
||||||
|
if command == 'history':
|
||||||
|
return _local_result(input_text, agent.render_ask_user_history_report())
|
||||||
|
return _local_result(input_text, 'Usage: /ask [history]')
|
||||||
|
|
||||||
|
|
||||||
def _handle_login(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
def _handle_login(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||||
target = args.strip()
|
target = args.strip()
|
||||||
if not target:
|
if not target:
|
||||||
@@ -429,6 +458,23 @@ def _handle_tasks(agent: 'LocalCodingAgent', args: str, input_text: str) -> Slas
|
|||||||
return _local_result(input_text, agent.render_tasks_report(status))
|
return _local_result(input_text, agent.render_tasks_report(status))
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_teams(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||||
|
query = args or None
|
||||||
|
return _local_result(input_text, agent.render_teams_report(query))
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_team(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||||
|
team_name = args.strip()
|
||||||
|
if not team_name:
|
||||||
|
return _local_result(input_text, 'Usage: /team <team-name>')
|
||||||
|
return _local_result(input_text, agent.render_team_report(team_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_messages(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||||
|
team_name = args.strip() or None
|
||||||
|
return _local_result(input_text, agent.render_team_messages_report(team_name))
|
||||||
|
|
||||||
|
|
||||||
def _handle_task_next(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
def _handle_task_next(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||||
return _local_result(input_text, agent.render_next_tasks_report())
|
return _local_result(input_text, agent.render_next_tasks_report())
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResu
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .account_runtime import AccountRuntime
|
from .account_runtime import AccountRuntime
|
||||||
|
from .ask_user_runtime import AskUserRuntime
|
||||||
from .config_runtime import ConfigRuntime
|
from .config_runtime import ConfigRuntime
|
||||||
from .mcp_runtime import MCPRuntime
|
from .mcp_runtime import MCPRuntime
|
||||||
from .plan_runtime import PlanRuntime
|
from .plan_runtime import PlanRuntime
|
||||||
from .remote_runtime import RemoteRuntime
|
from .remote_runtime import RemoteRuntime
|
||||||
from .search_runtime import SearchRuntime
|
from .search_runtime import SearchRuntime
|
||||||
from .task_runtime import TaskRuntime
|
from .task_runtime import TaskRuntime
|
||||||
|
from .team_runtime import TeamRuntime
|
||||||
|
|
||||||
|
|
||||||
class ToolPermissionError(RuntimeError):
|
class ToolPermissionError(RuntimeError):
|
||||||
@@ -44,11 +46,13 @@ class ToolExecutionContext:
|
|||||||
tool_registry: dict[str, 'AgentTool'] | None = None
|
tool_registry: dict[str, 'AgentTool'] | None = None
|
||||||
search_runtime: 'SearchRuntime | None' = None
|
search_runtime: 'SearchRuntime | None' = None
|
||||||
account_runtime: 'AccountRuntime | None' = None
|
account_runtime: 'AccountRuntime | None' = None
|
||||||
|
ask_user_runtime: 'AskUserRuntime | None' = None
|
||||||
config_runtime: 'ConfigRuntime | None' = None
|
config_runtime: 'ConfigRuntime | None' = None
|
||||||
mcp_runtime: 'MCPRuntime | None' = None
|
mcp_runtime: 'MCPRuntime | None' = None
|
||||||
remote_runtime: 'RemoteRuntime | None' = None
|
remote_runtime: 'RemoteRuntime | None' = None
|
||||||
plan_runtime: 'PlanRuntime | None' = None
|
plan_runtime: 'PlanRuntime | None' = None
|
||||||
task_runtime: 'TaskRuntime | None' = None
|
task_runtime: 'TaskRuntime | None' = None
|
||||||
|
team_runtime: 'TeamRuntime | None' = None
|
||||||
|
|
||||||
|
|
||||||
ToolHandler = Callable[
|
ToolHandler = Callable[
|
||||||
@@ -114,11 +118,13 @@ def build_tool_context(
|
|||||||
tool_registry: dict[str, AgentTool] | None = None,
|
tool_registry: dict[str, AgentTool] | None = None,
|
||||||
search_runtime: 'SearchRuntime | None' = None,
|
search_runtime: 'SearchRuntime | None' = None,
|
||||||
account_runtime: 'AccountRuntime | None' = None,
|
account_runtime: 'AccountRuntime | None' = None,
|
||||||
|
ask_user_runtime: 'AskUserRuntime | None' = None,
|
||||||
config_runtime: 'ConfigRuntime | None' = None,
|
config_runtime: 'ConfigRuntime | None' = None,
|
||||||
mcp_runtime: 'MCPRuntime | None' = None,
|
mcp_runtime: 'MCPRuntime | None' = None,
|
||||||
remote_runtime: 'RemoteRuntime | None' = None,
|
remote_runtime: 'RemoteRuntime | None' = None,
|
||||||
plan_runtime: 'PlanRuntime | None' = None,
|
plan_runtime: 'PlanRuntime | None' = None,
|
||||||
task_runtime: 'TaskRuntime | None' = None,
|
task_runtime: 'TaskRuntime | None' = None,
|
||||||
|
team_runtime: 'TeamRuntime | None' = None,
|
||||||
) -> ToolExecutionContext:
|
) -> ToolExecutionContext:
|
||||||
return ToolExecutionContext(
|
return ToolExecutionContext(
|
||||||
root=config.cwd.resolve(),
|
root=config.cwd.resolve(),
|
||||||
@@ -129,11 +135,13 @@ def build_tool_context(
|
|||||||
tool_registry=tool_registry,
|
tool_registry=tool_registry,
|
||||||
search_runtime=search_runtime,
|
search_runtime=search_runtime,
|
||||||
account_runtime=account_runtime,
|
account_runtime=account_runtime,
|
||||||
|
ask_user_runtime=ask_user_runtime,
|
||||||
config_runtime=config_runtime,
|
config_runtime=config_runtime,
|
||||||
mcp_runtime=mcp_runtime,
|
mcp_runtime=mcp_runtime,
|
||||||
remote_runtime=remote_runtime,
|
remote_runtime=remote_runtime,
|
||||||
plan_runtime=plan_runtime,
|
plan_runtime=plan_runtime,
|
||||||
task_runtime=task_runtime,
|
task_runtime=task_runtime,
|
||||||
|
team_runtime=team_runtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -238,6 +246,22 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
|||||||
},
|
},
|
||||||
handler=_edit_file,
|
handler=_edit_file,
|
||||||
),
|
),
|
||||||
|
AgentTool(
|
||||||
|
name='notebook_edit',
|
||||||
|
description='Edit a Jupyter notebook cell by replacing or appending source in a .ipynb file.',
|
||||||
|
parameters={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'path': {'type': 'string'},
|
||||||
|
'cell_index': {'type': 'integer', 'minimum': 0},
|
||||||
|
'source': {'type': 'string'},
|
||||||
|
'cell_type': {'type': 'string'},
|
||||||
|
'create_cell': {'type': 'boolean'},
|
||||||
|
},
|
||||||
|
'required': ['path', 'cell_index', 'source'],
|
||||||
|
},
|
||||||
|
handler=_notebook_edit,
|
||||||
|
),
|
||||||
AgentTool(
|
AgentTool(
|
||||||
name='glob_search',
|
name='glob_search',
|
||||||
description='Find files matching a glob pattern inside the workspace.',
|
description='Find files matching a glob pattern inside the workspace.',
|
||||||
@@ -368,6 +392,25 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
|||||||
},
|
},
|
||||||
handler=_sleep,
|
handler=_sleep,
|
||||||
),
|
),
|
||||||
|
AgentTool(
|
||||||
|
name='ask_user_question',
|
||||||
|
description='Request an answer from the local ask-user runtime using queued or interactive answers.',
|
||||||
|
parameters={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'question': {'type': 'string'},
|
||||||
|
'header': {'type': 'string'},
|
||||||
|
'question_id': {'type': 'string'},
|
||||||
|
'choices': {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {'type': 'string'},
|
||||||
|
},
|
||||||
|
'allow_free_text': {'type': 'boolean'},
|
||||||
|
},
|
||||||
|
'required': ['question'],
|
||||||
|
},
|
||||||
|
handler=_ask_user_question,
|
||||||
|
),
|
||||||
AgentTool(
|
AgentTool(
|
||||||
name='account_status',
|
name='account_status',
|
||||||
description='Show local account runtime summary or a specific configured account profile.',
|
description='Show local account runtime summary or a specific configured account profile.',
|
||||||
@@ -631,6 +674,85 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
|||||||
},
|
},
|
||||||
handler=_task_next,
|
handler=_task_next,
|
||||||
),
|
),
|
||||||
|
AgentTool(
|
||||||
|
name='team_list',
|
||||||
|
description='List locally configured collaboration teams.',
|
||||||
|
parameters={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'query': {'type': 'string'},
|
||||||
|
'max_teams': {'type': 'integer', 'minimum': 1, 'maximum': 200},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler=_team_list,
|
||||||
|
),
|
||||||
|
AgentTool(
|
||||||
|
name='team_get',
|
||||||
|
description='Show a locally configured collaboration team by name.',
|
||||||
|
parameters={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'team_name': {'type': 'string'},
|
||||||
|
},
|
||||||
|
'required': ['team_name'],
|
||||||
|
},
|
||||||
|
handler=_team_get,
|
||||||
|
),
|
||||||
|
AgentTool(
|
||||||
|
name='team_create',
|
||||||
|
description='Create a locally stored collaboration team.',
|
||||||
|
parameters={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'team_name': {'type': 'string'},
|
||||||
|
'description': {'type': 'string'},
|
||||||
|
'members': {'type': 'array', 'items': {'type': 'string'}},
|
||||||
|
'metadata': {'type': 'object'},
|
||||||
|
},
|
||||||
|
'required': ['team_name'],
|
||||||
|
},
|
||||||
|
handler=_team_create,
|
||||||
|
),
|
||||||
|
AgentTool(
|
||||||
|
name='team_delete',
|
||||||
|
description='Delete a locally stored collaboration team and its recorded messages.',
|
||||||
|
parameters={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'team_name': {'type': 'string'},
|
||||||
|
},
|
||||||
|
'required': ['team_name'],
|
||||||
|
},
|
||||||
|
handler=_team_delete,
|
||||||
|
),
|
||||||
|
AgentTool(
|
||||||
|
name='send_message',
|
||||||
|
description='Send a local collaboration message to a team or teammate and persist it in the team runtime.',
|
||||||
|
parameters={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'team_name': {'type': 'string'},
|
||||||
|
'message': {'type': 'string'},
|
||||||
|
'sender': {'type': 'string'},
|
||||||
|
'recipient': {'type': 'string'},
|
||||||
|
'metadata': {'type': 'object'},
|
||||||
|
},
|
||||||
|
'required': ['team_name', 'message'],
|
||||||
|
},
|
||||||
|
handler=_send_message,
|
||||||
|
),
|
||||||
|
AgentTool(
|
||||||
|
name='team_messages',
|
||||||
|
description='Show locally recorded collaboration messages for all teams or one team.',
|
||||||
|
parameters={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'team_name': {'type': 'string'},
|
||||||
|
'limit': {'type': 'integer', 'minimum': 1, 'maximum': 200},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler=_team_messages,
|
||||||
|
),
|
||||||
AgentTool(
|
AgentTool(
|
||||||
name='task_list',
|
name='task_list',
|
||||||
description='List locally stored runtime tasks.',
|
description='List locally stored runtime tasks.',
|
||||||
@@ -1047,6 +1169,85 @@ def _edit_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notebook_edit(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||||
|
_ensure_write_allowed(context)
|
||||||
|
target = _resolve_path(_require_string(arguments, 'path'), context, allow_missing=False)
|
||||||
|
if target.suffix != '.ipynb':
|
||||||
|
raise ToolExecutionError('notebook_edit requires a .ipynb target')
|
||||||
|
if not target.is_file():
|
||||||
|
raise ToolExecutionError(f'Path is not a file: {target}')
|
||||||
|
cell_index = arguments.get('cell_index')
|
||||||
|
if isinstance(cell_index, bool) or not isinstance(cell_index, int) or cell_index < 0:
|
||||||
|
raise ToolExecutionError('cell_index must be an integer >= 0')
|
||||||
|
source = arguments.get('source')
|
||||||
|
if not isinstance(source, str):
|
||||||
|
raise ToolExecutionError('source must be a string')
|
||||||
|
cell_type = arguments.get('cell_type', 'code')
|
||||||
|
if cell_type is not None and not isinstance(cell_type, str):
|
||||||
|
raise ToolExecutionError('cell_type must be a string')
|
||||||
|
create_cell = arguments.get('create_cell', False)
|
||||||
|
if not isinstance(create_cell, bool):
|
||||||
|
raise ToolExecutionError('create_cell must be a boolean')
|
||||||
|
|
||||||
|
raw = target.read_text(encoding='utf-8', errors='replace')
|
||||||
|
before_sha256 = hashlib.sha256(raw.encode('utf-8')).hexdigest()
|
||||||
|
try:
|
||||||
|
notebook = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ToolExecutionError(f'Notebook is not valid JSON: {target}') from exc
|
||||||
|
if not isinstance(notebook, dict):
|
||||||
|
raise ToolExecutionError('Notebook payload must be a JSON object')
|
||||||
|
cells = notebook.get('cells')
|
||||||
|
if not isinstance(cells, list):
|
||||||
|
raise ToolExecutionError('Notebook does not contain a cells array')
|
||||||
|
|
||||||
|
while len(cells) <= cell_index:
|
||||||
|
if not create_cell:
|
||||||
|
raise ToolExecutionError(
|
||||||
|
f'Notebook cell {cell_index} does not exist; pass create_cell=true to append missing cells'
|
||||||
|
)
|
||||||
|
cells.append(
|
||||||
|
{
|
||||||
|
'cell_type': cell_type or 'code',
|
||||||
|
'metadata': {},
|
||||||
|
'source': [],
|
||||||
|
'outputs': [],
|
||||||
|
'execution_count': None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cell = cells[cell_index]
|
||||||
|
if not isinstance(cell, dict):
|
||||||
|
raise ToolExecutionError(f'Notebook cell {cell_index} is not a JSON object')
|
||||||
|
existing_type = cell.get('cell_type')
|
||||||
|
if not isinstance(existing_type, str):
|
||||||
|
existing_type = 'code'
|
||||||
|
source_lines = source.splitlines(keepends=True)
|
||||||
|
if source and not source.endswith('\n'):
|
||||||
|
source_lines = [*source_lines[:-1], source_lines[-1]]
|
||||||
|
cell['cell_type'] = cell_type or existing_type
|
||||||
|
cell['source'] = source_lines
|
||||||
|
if cell['cell_type'] == 'code':
|
||||||
|
cell.setdefault('outputs', [])
|
||||||
|
cell.setdefault('execution_count', None)
|
||||||
|
updated = json.dumps(notebook, ensure_ascii=True, indent=1) + '\n'
|
||||||
|
target.write_text(updated, encoding='utf-8')
|
||||||
|
after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest()
|
||||||
|
rel = target.relative_to(context.root)
|
||||||
|
return (
|
||||||
|
f'updated notebook cell {cell_index} in {rel}',
|
||||||
|
{
|
||||||
|
'action': 'notebook_edit',
|
||||||
|
'path': str(rel),
|
||||||
|
'cell_index': cell_index,
|
||||||
|
'cell_type': cell['cell_type'],
|
||||||
|
'before_sha256': before_sha256,
|
||||||
|
'after_sha256': after_sha256,
|
||||||
|
'before_preview': _snapshot_text(raw),
|
||||||
|
'after_preview': _snapshot_text(updated),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _glob_search(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
def _glob_search(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||||
pattern = _require_string(arguments, 'pattern')
|
pattern = _require_string(arguments, 'pattern')
|
||||||
matches = sorted(context.root.glob(pattern))
|
matches = sorted(context.root.glob(pattern))
|
||||||
@@ -1321,6 +1522,69 @@ def _sleep(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_user_question(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||||
|
runtime = _require_ask_user_runtime(context)
|
||||||
|
question = _require_string(arguments, 'question')
|
||||||
|
header = arguments.get('header')
|
||||||
|
question_id = arguments.get('question_id')
|
||||||
|
if header is not None and not isinstance(header, str):
|
||||||
|
raise ToolExecutionError('header must be a string')
|
||||||
|
if question_id is not None and not isinstance(question_id, str):
|
||||||
|
raise ToolExecutionError('question_id must be a string')
|
||||||
|
raw_choices = arguments.get('choices', ())
|
||||||
|
if raw_choices is None:
|
||||||
|
raw_choices = ()
|
||||||
|
if not isinstance(raw_choices, (list, tuple)):
|
||||||
|
raise ToolExecutionError('choices must be an array of strings')
|
||||||
|
choices = tuple(
|
||||||
|
item.strip()
|
||||||
|
for item in raw_choices
|
||||||
|
if isinstance(item, str) and item.strip()
|
||||||
|
)
|
||||||
|
if len(choices) != len(raw_choices):
|
||||||
|
raise ToolExecutionError('choices must contain only non-empty strings')
|
||||||
|
allow_free_text = arguments.get('allow_free_text', True)
|
||||||
|
if not isinstance(allow_free_text, bool):
|
||||||
|
raise ToolExecutionError('allow_free_text must be a boolean')
|
||||||
|
try:
|
||||||
|
response = runtime.answer(
|
||||||
|
question=question,
|
||||||
|
choices=choices,
|
||||||
|
question_id=question_id,
|
||||||
|
header=header,
|
||||||
|
allow_free_text=allow_free_text,
|
||||||
|
)
|
||||||
|
except LookupError as exc:
|
||||||
|
raise ToolExecutionError(str(exc)) from exc
|
||||||
|
lines = ['# Ask User', '']
|
||||||
|
if header:
|
||||||
|
lines.append(f'- Header: {header}')
|
||||||
|
if question_id:
|
||||||
|
lines.append(f'- Question ID: {question_id}')
|
||||||
|
lines.append(f'- Question: {question}')
|
||||||
|
if choices:
|
||||||
|
lines.append('- Choices: ' + ', '.join(choices))
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f'- Source: {response.source}',
|
||||||
|
'',
|
||||||
|
response.answer,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'\n'.join(lines),
|
||||||
|
{
|
||||||
|
'action': 'ask_user_question',
|
||||||
|
'question': question,
|
||||||
|
'question_id': question_id,
|
||||||
|
'header': header,
|
||||||
|
'source': response.source,
|
||||||
|
'answer_preview': _snapshot_text(response.answer),
|
||||||
|
'choices': list(choices),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _account_status(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
def _account_status(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||||
runtime = _require_account_runtime(context)
|
runtime = _require_account_runtime(context)
|
||||||
profile = arguments.get('profile')
|
profile = arguments.get('profile')
|
||||||
@@ -1579,6 +1843,125 @@ def _remote_disconnect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _team_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||||
|
runtime = _require_team_runtime(context)
|
||||||
|
query = arguments.get('query')
|
||||||
|
if query is not None and not isinstance(query, str):
|
||||||
|
raise ToolExecutionError('query must be a string')
|
||||||
|
max_teams = _coerce_int(arguments, 'max_teams', 50)
|
||||||
|
return runtime.render_teams_index(query=query, limit=max_teams)
|
||||||
|
|
||||||
|
|
||||||
|
def _team_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||||
|
runtime = _require_team_runtime(context)
|
||||||
|
team_name = _require_string(arguments, 'team_name')
|
||||||
|
try:
|
||||||
|
return runtime.render_team(team_name)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ToolExecutionError(f'Unknown team: {team_name}') from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _team_create(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||||
|
_ensure_write_allowed(context)
|
||||||
|
runtime = _require_team_runtime(context)
|
||||||
|
team_name = _require_string(arguments, 'team_name')
|
||||||
|
description = arguments.get('description')
|
||||||
|
members = arguments.get('members', ())
|
||||||
|
metadata = arguments.get('metadata')
|
||||||
|
if description is not None and not isinstance(description, str):
|
||||||
|
raise ToolExecutionError('description must be a string')
|
||||||
|
if not isinstance(members, (list, tuple)):
|
||||||
|
raise ToolExecutionError('members must be an array of strings')
|
||||||
|
if metadata is not None and not isinstance(metadata, dict):
|
||||||
|
raise ToolExecutionError('metadata must be an object')
|
||||||
|
try:
|
||||||
|
team = runtime.create_team(
|
||||||
|
team_name,
|
||||||
|
description=description,
|
||||||
|
members=members,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ToolExecutionError(f'Team already exists: {team_name}') from exc
|
||||||
|
return (
|
||||||
|
f'created team {team.name}',
|
||||||
|
{
|
||||||
|
'action': 'team_create',
|
||||||
|
'team_name': team.name,
|
||||||
|
'member_count': len(team.members),
|
||||||
|
'path': str(runtime.state_path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _team_delete(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||||
|
_ensure_write_allowed(context)
|
||||||
|
runtime = _require_team_runtime(context)
|
||||||
|
team_name = _require_string(arguments, 'team_name')
|
||||||
|
try:
|
||||||
|
team = runtime.delete_team(team_name)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ToolExecutionError(f'Unknown team: {team_name}') from exc
|
||||||
|
return (
|
||||||
|
f'deleted team {team.name}',
|
||||||
|
{
|
||||||
|
'action': 'team_delete',
|
||||||
|
'team_name': team.name,
|
||||||
|
'path': str(runtime.state_path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_message(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||||
|
_ensure_write_allowed(context)
|
||||||
|
runtime = _require_team_runtime(context)
|
||||||
|
team_name = _require_string(arguments, 'team_name')
|
||||||
|
message = _require_string(arguments, 'message')
|
||||||
|
sender = arguments.get('sender', 'agent')
|
||||||
|
recipient = arguments.get('recipient')
|
||||||
|
metadata = arguments.get('metadata')
|
||||||
|
if sender is not None and not isinstance(sender, str):
|
||||||
|
raise ToolExecutionError('sender must be a string')
|
||||||
|
if recipient is not None and not isinstance(recipient, str):
|
||||||
|
raise ToolExecutionError('recipient must be a string')
|
||||||
|
if metadata is not None and not isinstance(metadata, dict):
|
||||||
|
raise ToolExecutionError('metadata must be an object')
|
||||||
|
try:
|
||||||
|
stored = runtime.send_message(
|
||||||
|
team_name=team_name,
|
||||||
|
text=message,
|
||||||
|
sender=sender or 'agent',
|
||||||
|
recipient=recipient,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ToolExecutionError(f'Unknown team: {team_name}') from exc
|
||||||
|
return (
|
||||||
|
f'sent message to team {stored.team_name}',
|
||||||
|
{
|
||||||
|
'action': 'send_message',
|
||||||
|
'team_name': stored.team_name,
|
||||||
|
'sender': stored.sender,
|
||||||
|
'recipient': stored.recipient,
|
||||||
|
'message_id': stored.message_id,
|
||||||
|
'message_preview': _snapshot_text(stored.text),
|
||||||
|
'path': str(runtime.state_path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _team_messages(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||||
|
runtime = _require_team_runtime(context)
|
||||||
|
team_name = arguments.get('team_name')
|
||||||
|
if team_name is not None and not isinstance(team_name, str):
|
||||||
|
raise ToolExecutionError('team_name must be a string')
|
||||||
|
limit = _coerce_int(arguments, 'limit', 20)
|
||||||
|
try:
|
||||||
|
return runtime.render_messages(team_name=team_name, limit=limit)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ToolExecutionError(f'Unknown team: {team_name}') from exc
|
||||||
|
|
||||||
|
|
||||||
def _task_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
def _task_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||||
runtime = _require_task_runtime(context)
|
runtime = _require_task_runtime(context)
|
||||||
status = arguments.get('status')
|
status = arguments.get('status')
|
||||||
@@ -2048,6 +2431,12 @@ def _require_account_runtime(context: ToolExecutionContext):
|
|||||||
return context.account_runtime
|
return context.account_runtime
|
||||||
|
|
||||||
|
|
||||||
|
def _require_ask_user_runtime(context: ToolExecutionContext):
|
||||||
|
if context.ask_user_runtime is None:
|
||||||
|
raise ToolExecutionError('No local ask-user runtime is available.')
|
||||||
|
return context.ask_user_runtime
|
||||||
|
|
||||||
|
|
||||||
def _require_search_runtime(context: ToolExecutionContext):
|
def _require_search_runtime(context: ToolExecutionContext):
|
||||||
if context.search_runtime is None or not context.search_runtime.has_search_runtime():
|
if context.search_runtime is None or not context.search_runtime.has_search_runtime():
|
||||||
raise ToolExecutionError(
|
raise ToolExecutionError(
|
||||||
@@ -2095,6 +2484,12 @@ def _require_task_runtime(context: ToolExecutionContext):
|
|||||||
return context.task_runtime
|
return context.task_runtime
|
||||||
|
|
||||||
|
|
||||||
|
def _require_team_runtime(context: ToolExecutionContext):
|
||||||
|
if context.team_runtime is None:
|
||||||
|
raise ToolExecutionError('Local team runtime is not available.')
|
||||||
|
return context.team_runtime
|
||||||
|
|
||||||
|
|
||||||
def _task_mutation_metadata(
|
def _task_mutation_metadata(
|
||||||
*,
|
*,
|
||||||
action: str,
|
action: str,
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ASK_USER_STATE_FILE = Path('.port_sessions') / 'ask_user_runtime.json'
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QueuedUserAnswer:
|
||||||
|
answer: str
|
||||||
|
question: str | None = None
|
||||||
|
question_id: str | None = None
|
||||||
|
header: str | None = None
|
||||||
|
match: str = 'exact'
|
||||||
|
consume: bool = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, payload: dict[str, Any]) -> 'QueuedUserAnswer | None':
|
||||||
|
answer = payload.get('answer')
|
||||||
|
if not isinstance(answer, str) or not answer.strip():
|
||||||
|
return None
|
||||||
|
question = payload.get('question')
|
||||||
|
if question is not None and not isinstance(question, str):
|
||||||
|
question = None
|
||||||
|
question_id = payload.get('question_id')
|
||||||
|
if question_id is not None and not isinstance(question_id, str):
|
||||||
|
question_id = None
|
||||||
|
header = payload.get('header')
|
||||||
|
if header is not None and not isinstance(header, str):
|
||||||
|
header = None
|
||||||
|
match = payload.get('match')
|
||||||
|
if not isinstance(match, str) or match not in {'exact', 'contains'}:
|
||||||
|
match = 'exact'
|
||||||
|
consume = payload.get('consume', True)
|
||||||
|
if not isinstance(consume, bool):
|
||||||
|
consume = True
|
||||||
|
return cls(
|
||||||
|
answer=answer,
|
||||||
|
question=question.strip() if question else None,
|
||||||
|
question_id=question_id.strip() if question_id else None,
|
||||||
|
header=header.strip() if header else None,
|
||||||
|
match=match,
|
||||||
|
consume=consume,
|
||||||
|
)
|
||||||
|
|
||||||
|
def matches(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
question: str,
|
||||||
|
question_id: str | None,
|
||||||
|
header: str | None,
|
||||||
|
) -> bool:
|
||||||
|
if question_id and self.question_id and self.question_id == question_id:
|
||||||
|
return True
|
||||||
|
if header and self.header and self.header.lower() == header.lower():
|
||||||
|
return True
|
||||||
|
if self.question is None:
|
||||||
|
return False
|
||||||
|
if self.match == 'contains':
|
||||||
|
return self.question.lower() in question.lower()
|
||||||
|
return self.question.strip().lower() == question.strip().lower()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'answer': self.answer,
|
||||||
|
'question': self.question,
|
||||||
|
'question_id': self.question_id,
|
||||||
|
'header': self.header,
|
||||||
|
'match': self.match,
|
||||||
|
'consume': self.consume,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AskUserResponse:
|
||||||
|
answer: str
|
||||||
|
source: str
|
||||||
|
matched_question: str | None = None
|
||||||
|
question_id: str | None = None
|
||||||
|
header: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AskUserRuntime:
|
||||||
|
cwd: Path
|
||||||
|
queued_answers: tuple[QueuedUserAnswer, ...] = field(default_factory=tuple)
|
||||||
|
history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
||||||
|
manifests: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
state_path: Path = field(default_factory=lambda: DEFAULT_ASK_USER_STATE_FILE.resolve())
|
||||||
|
interactive: bool = False
|
||||||
|
input_func: Callable[[str], str] | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_workspace(
|
||||||
|
cls,
|
||||||
|
cwd: Path,
|
||||||
|
additional_working_directories: tuple[str, ...] = (),
|
||||||
|
*,
|
||||||
|
interactive: bool | None = None,
|
||||||
|
input_func: Callable[[str], str] | None = None,
|
||||||
|
) -> 'AskUserRuntime':
|
||||||
|
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
|
||||||
|
manifest_answers: list[QueuedUserAnswer] = []
|
||||||
|
for manifest_path in manifest_paths:
|
||||||
|
manifest_answers.extend(_load_answers_from_manifest(manifest_path))
|
||||||
|
state_path = cwd.resolve() / DEFAULT_ASK_USER_STATE_FILE
|
||||||
|
payload = _load_state_payload(state_path)
|
||||||
|
queued_payload = payload.get('queued_answers')
|
||||||
|
if isinstance(queued_payload, list):
|
||||||
|
queued_answers = tuple(
|
||||||
|
answer
|
||||||
|
for answer in (
|
||||||
|
QueuedUserAnswer.from_dict(item)
|
||||||
|
for item in queued_payload
|
||||||
|
if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
if answer is not None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
queued_answers = tuple(manifest_answers)
|
||||||
|
history_payload = payload.get('history')
|
||||||
|
history = tuple(item for item in history_payload if isinstance(item, dict)) if isinstance(history_payload, list) else ()
|
||||||
|
if interactive is None:
|
||||||
|
env_value = os.environ.get('CLAW_ASK_USER_INTERACTIVE', '')
|
||||||
|
interactive = env_value.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||||
|
return cls(
|
||||||
|
cwd=cwd.resolve(),
|
||||||
|
queued_answers=queued_answers,
|
||||||
|
history=history,
|
||||||
|
manifests=tuple(str(path) for path in manifest_paths),
|
||||||
|
state_path=state_path,
|
||||||
|
interactive=bool(interactive),
|
||||||
|
input_func=input_func,
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_state(self) -> bool:
|
||||||
|
return bool(self.queued_answers or self.history or self.manifests)
|
||||||
|
|
||||||
|
def answer(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
question: str,
|
||||||
|
choices: tuple[str, ...] = (),
|
||||||
|
question_id: str | None = None,
|
||||||
|
header: str | None = None,
|
||||||
|
allow_free_text: bool = True,
|
||||||
|
) -> AskUserResponse:
|
||||||
|
for index, entry in enumerate(self.queued_answers):
|
||||||
|
if not entry.matches(question=question, question_id=question_id, header=header):
|
||||||
|
continue
|
||||||
|
if entry.consume:
|
||||||
|
queued_answers = list(self.queued_answers)
|
||||||
|
queued_answers.pop(index)
|
||||||
|
self.queued_answers = tuple(queued_answers)
|
||||||
|
response = AskUserResponse(
|
||||||
|
answer=entry.answer,
|
||||||
|
source='queued',
|
||||||
|
matched_question=entry.question,
|
||||||
|
question_id=question_id or entry.question_id,
|
||||||
|
header=header or entry.header,
|
||||||
|
)
|
||||||
|
self._record_history(question, response, choices=choices)
|
||||||
|
self._persist_state()
|
||||||
|
return response
|
||||||
|
|
||||||
|
if self.interactive and self.input_func is not None:
|
||||||
|
prompt_lines = ['# Ask User']
|
||||||
|
if header:
|
||||||
|
prompt_lines.append(f'header={header}')
|
||||||
|
if question_id:
|
||||||
|
prompt_lines.append(f'question_id={question_id}')
|
||||||
|
prompt_lines.append(question)
|
||||||
|
if choices:
|
||||||
|
prompt_lines.append('choices=' + ', '.join(choices))
|
||||||
|
raw_answer = self.input_func('\n'.join(prompt_lines) + '\nanswer> ')
|
||||||
|
answer = raw_answer.strip()
|
||||||
|
if not answer:
|
||||||
|
raise LookupError('Interactive ask-user prompt returned an empty answer.')
|
||||||
|
if choices and not allow_free_text and answer not in choices:
|
||||||
|
raise LookupError(
|
||||||
|
'Interactive answer did not match the allowed choices: '
|
||||||
|
+ ', '.join(choices)
|
||||||
|
)
|
||||||
|
response = AskUserResponse(
|
||||||
|
answer=answer,
|
||||||
|
source='interactive',
|
||||||
|
question_id=question_id,
|
||||||
|
header=header,
|
||||||
|
)
|
||||||
|
self._record_history(question, response, choices=choices)
|
||||||
|
self._persist_state()
|
||||||
|
return response
|
||||||
|
|
||||||
|
raise LookupError(
|
||||||
|
'No queued ask-user answer is available. '
|
||||||
|
'Add .claw-ask-user.json or enable CLAW_ASK_USER_INTERACTIVE=1 for interactive prompting.'
|
||||||
|
)
|
||||||
|
|
||||||
|
def render_summary(self) -> str:
|
||||||
|
lines = [
|
||||||
|
f'Ask-user manifests: {len(self.manifests)}',
|
||||||
|
f'Queued answers: {len(self.queued_answers)}',
|
||||||
|
f'History entries: {len(self.history)}',
|
||||||
|
f'Interactive mode: {self.interactive}',
|
||||||
|
]
|
||||||
|
if self.queued_answers:
|
||||||
|
lines.append('- Pending queued answers:')
|
||||||
|
for entry in self.queued_answers[:10]:
|
||||||
|
label = entry.question_id or entry.header or entry.question or '(wildcard answer)'
|
||||||
|
lines.append(f' - {label}')
|
||||||
|
if len(self.queued_answers) > 10:
|
||||||
|
lines.append(f' - ... plus {len(self.queued_answers) - 10} more')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
def render_history(self, *, limit: int = 20) -> str:
|
||||||
|
lines = ['# Ask User History', '']
|
||||||
|
entries = list(self.history[-limit:])
|
||||||
|
if not entries:
|
||||||
|
lines.append('No ask-user interactions recorded.')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
for entry in entries:
|
||||||
|
lines.append(f"- {entry.get('created_at', '(unknown time)')} :: {entry.get('question', '(unknown question)')}")
|
||||||
|
lines.append(f" - answer={entry.get('answer', '')}")
|
||||||
|
lines.append(f" - source={entry.get('source', 'unknown')}")
|
||||||
|
if entry.get('choices'):
|
||||||
|
lines.append(' - choices=' + ', '.join(entry['choices']))
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
def _record_history(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
response: AskUserResponse,
|
||||||
|
*,
|
||||||
|
choices: tuple[str, ...],
|
||||||
|
) -> None:
|
||||||
|
entry = {
|
||||||
|
'question': question,
|
||||||
|
'answer': response.answer,
|
||||||
|
'source': response.source,
|
||||||
|
'question_id': response.question_id,
|
||||||
|
'header': response.header,
|
||||||
|
'choices': list(choices),
|
||||||
|
'created_at': _utc_now(),
|
||||||
|
}
|
||||||
|
self.history = (*self.history, entry)
|
||||||
|
|
||||||
|
def _persist_state(self) -> None:
|
||||||
|
payload = {
|
||||||
|
'queued_answers': [answer.to_dict() for answer in self.queued_answers],
|
||||||
|
'history': list(self.history),
|
||||||
|
}
|
||||||
|
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.state_path.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=True, indent=2),
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_manifest_paths(cwd: Path, additional_working_directories: tuple[str, ...]) -> tuple[Path, ...]:
|
||||||
|
candidates = [
|
||||||
|
cwd.resolve() / '.claw-ask-user.json',
|
||||||
|
cwd.resolve() / '.claude' / 'ask-user.json',
|
||||||
|
]
|
||||||
|
for raw_path in additional_working_directories:
|
||||||
|
root = Path(raw_path).resolve()
|
||||||
|
candidates.extend(
|
||||||
|
[
|
||||||
|
root / '.claw-ask-user.json',
|
||||||
|
root / '.claude' / 'ask-user.json',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
discovered: list[Path] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for candidate in candidates:
|
||||||
|
if not candidate.is_file():
|
||||||
|
continue
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
if resolved in seen:
|
||||||
|
continue
|
||||||
|
seen.add(resolved)
|
||||||
|
discovered.append(resolved)
|
||||||
|
return tuple(discovered)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_answers_from_manifest(path: Path) -> list[QueuedUserAnswer]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
answers_payload = payload.get('answers')
|
||||||
|
if not isinstance(answers_payload, list):
|
||||||
|
return []
|
||||||
|
answers: list[QueuedUserAnswer] = []
|
||||||
|
for item in answers_payload:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
answer = QueuedUserAnswer.from_dict(item)
|
||||||
|
if answer is not None:
|
||||||
|
answers.append(answer)
|
||||||
|
return answers
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state_payload(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return payload if isinstance(payload, dict) else {}
|
||||||
+83
@@ -10,6 +10,7 @@ from typing import Callable
|
|||||||
|
|
||||||
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
|
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
|
||||||
from .account_runtime import AccountRuntime
|
from .account_runtime import AccountRuntime
|
||||||
|
from .ask_user_runtime import AskUserRuntime
|
||||||
from .agent_runtime import LocalCodingAgent
|
from .agent_runtime import LocalCodingAgent
|
||||||
from .agent_types import (
|
from .agent_types import (
|
||||||
AgentPermissions,
|
AgentPermissions,
|
||||||
@@ -37,6 +38,7 @@ from .remote_runtime import (
|
|||||||
run_teleport_mode,
|
run_teleport_mode,
|
||||||
)
|
)
|
||||||
from .search_runtime import SearchRuntime
|
from .search_runtime import SearchRuntime
|
||||||
|
from .team_runtime import TeamRuntime
|
||||||
from .runtime import PortRuntime
|
from .runtime import PortRuntime
|
||||||
from .session_store import (
|
from .session_store import (
|
||||||
StoredAgentSession,
|
StoredAgentSession,
|
||||||
@@ -613,6 +615,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
account_login_parser.add_argument('--cwd', default='.')
|
account_login_parser.add_argument('--cwd', default='.')
|
||||||
account_logout_parser = subparsers.add_parser('account-logout', help='clear the active local account session')
|
account_logout_parser = subparsers.add_parser('account-logout', help='clear the active local account session')
|
||||||
account_logout_parser.add_argument('--cwd', default='.')
|
account_logout_parser.add_argument('--cwd', default='.')
|
||||||
|
ask_status_parser = subparsers.add_parser('ask-status', help='show local ask-user runtime status')
|
||||||
|
ask_status_parser.add_argument('--cwd', default='.')
|
||||||
|
ask_history_parser = subparsers.add_parser('ask-history', help='show local ask-user interaction history')
|
||||||
|
ask_history_parser.add_argument('--cwd', default='.')
|
||||||
search_status_parser = subparsers.add_parser('search-status', help='show local search runtime status')
|
search_status_parser = subparsers.add_parser('search-status', help='show local search runtime status')
|
||||||
search_status_parser.add_argument('--cwd', default='.')
|
search_status_parser.add_argument('--cwd', default='.')
|
||||||
search_status_parser.add_argument('--provider')
|
search_status_parser.add_argument('--provider')
|
||||||
@@ -661,6 +667,25 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
config_set_parser.add_argument('value_json')
|
config_set_parser.add_argument('value_json')
|
||||||
config_set_parser.add_argument('--source', default='local')
|
config_set_parser.add_argument('--source', default='local')
|
||||||
config_set_parser.add_argument('--cwd', default='.')
|
config_set_parser.add_argument('--cwd', default='.')
|
||||||
|
teams_status_parser = subparsers.add_parser('team-status', help='show local collaboration team runtime summary')
|
||||||
|
teams_status_parser.add_argument('--cwd', default='.')
|
||||||
|
teams_list_parser = subparsers.add_parser('team-list', help='list local collaboration teams')
|
||||||
|
teams_list_parser.add_argument('--cwd', default='.')
|
||||||
|
teams_list_parser.add_argument('--query')
|
||||||
|
team_get_parser = subparsers.add_parser('team-get', help='show one local collaboration team')
|
||||||
|
team_get_parser.add_argument('team_name')
|
||||||
|
team_get_parser.add_argument('--cwd', default='.')
|
||||||
|
team_create_parser = subparsers.add_parser('team-create', help='create a local collaboration team')
|
||||||
|
team_create_parser.add_argument('team_name')
|
||||||
|
team_create_parser.add_argument('--description')
|
||||||
|
team_create_parser.add_argument('--member', action='append', default=[])
|
||||||
|
team_create_parser.add_argument('--cwd', default='.')
|
||||||
|
team_delete_parser = subparsers.add_parser('team-delete', help='delete a local collaboration team')
|
||||||
|
team_delete_parser.add_argument('team_name')
|
||||||
|
team_delete_parser.add_argument('--cwd', default='.')
|
||||||
|
team_messages_parser = subparsers.add_parser('team-messages', help='show local team messages')
|
||||||
|
team_messages_parser.add_argument('--team-name')
|
||||||
|
team_messages_parser.add_argument('--cwd', default='.')
|
||||||
|
|
||||||
show_command = subparsers.add_parser('show-command', help='show one mirrored command entry by exact name')
|
show_command = subparsers.add_parser('show-command', help='show one mirrored command entry by exact name')
|
||||||
show_command.add_argument('name')
|
show_command.add_argument('name')
|
||||||
@@ -905,6 +930,16 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
|
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
|
||||||
print(runtime.logout().as_text())
|
print(runtime.logout().as_text())
|
||||||
return 0
|
return 0
|
||||||
|
if args.command == 'ask-status':
|
||||||
|
runtime = AskUserRuntime.from_workspace(Path(args.cwd).resolve())
|
||||||
|
print('# Ask User')
|
||||||
|
print()
|
||||||
|
print(runtime.render_summary())
|
||||||
|
return 0
|
||||||
|
if args.command == 'ask-history':
|
||||||
|
runtime = AskUserRuntime.from_workspace(Path(args.cwd).resolve())
|
||||||
|
print(runtime.render_history())
|
||||||
|
return 0
|
||||||
if args.command == 'search-status':
|
if args.command == 'search-status':
|
||||||
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
|
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
|
||||||
if args.provider:
|
if args.provider:
|
||||||
@@ -1003,6 +1038,54 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print(f'effective_key_count={mutation.effective_key_count}')
|
print(f'effective_key_count={mutation.effective_key_count}')
|
||||||
print(runtime.render_value(args.key_path))
|
print(runtime.render_value(args.key_path))
|
||||||
return 0
|
return 0
|
||||||
|
if args.command == 'team-status':
|
||||||
|
runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve())
|
||||||
|
print('# Teams')
|
||||||
|
print()
|
||||||
|
print(runtime.render_summary())
|
||||||
|
return 0
|
||||||
|
if args.command == 'team-list':
|
||||||
|
runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve())
|
||||||
|
print(runtime.render_teams_index(query=args.query))
|
||||||
|
return 0
|
||||||
|
if args.command == 'team-get':
|
||||||
|
runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve())
|
||||||
|
try:
|
||||||
|
print(runtime.render_team(args.team_name))
|
||||||
|
except KeyError:
|
||||||
|
print(f'Unknown team: {args.team_name}')
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
if args.command == 'team-create':
|
||||||
|
runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve())
|
||||||
|
try:
|
||||||
|
team = runtime.create_team(
|
||||||
|
args.team_name,
|
||||||
|
description=args.description,
|
||||||
|
members=args.member,
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
print(f'Team already exists: {args.team_name}')
|
||||||
|
return 1
|
||||||
|
print(f'created team {team.name}')
|
||||||
|
return 0
|
||||||
|
if args.command == 'team-delete':
|
||||||
|
runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve())
|
||||||
|
try:
|
||||||
|
team = runtime.delete_team(args.team_name)
|
||||||
|
except KeyError:
|
||||||
|
print(f'Unknown team: {args.team_name}')
|
||||||
|
return 1
|
||||||
|
print(f'deleted team {team.name}')
|
||||||
|
return 0
|
||||||
|
if args.command == 'team-messages':
|
||||||
|
runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve())
|
||||||
|
try:
|
||||||
|
print(runtime.render_messages(team_name=args.team_name))
|
||||||
|
except KeyError:
|
||||||
|
print(f'Unknown team: {args.team_name}')
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
if args.command == 'show-command':
|
if args.command == 'show-command':
|
||||||
module = get_command(args.name)
|
module = get_command(args.name)
|
||||||
if module is None:
|
if module is None:
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TEAM_STATE_FILE = Path('.port_sessions') / 'team_runtime.json'
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TeamDefinition:
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
members: tuple[str, ...] = ()
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, payload: dict[str, Any]) -> 'TeamDefinition | None':
|
||||||
|
name = payload.get('name')
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
return None
|
||||||
|
description = payload.get('description')
|
||||||
|
if description is not None and not isinstance(description, str):
|
||||||
|
description = None
|
||||||
|
raw_members = payload.get('members', ())
|
||||||
|
members = tuple(
|
||||||
|
item.strip()
|
||||||
|
for item in raw_members
|
||||||
|
if isinstance(item, str) and item.strip()
|
||||||
|
) if isinstance(raw_members, (list, tuple)) else ()
|
||||||
|
metadata = payload.get('metadata', {})
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
metadata = {}
|
||||||
|
created_at = payload.get('created_at')
|
||||||
|
if not isinstance(created_at, str) or not created_at.strip():
|
||||||
|
created_at = _utc_now()
|
||||||
|
return cls(
|
||||||
|
name=name.strip(),
|
||||||
|
description=description.strip() if description else None,
|
||||||
|
members=members,
|
||||||
|
metadata=dict(metadata),
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'name': self.name,
|
||||||
|
'description': self.description,
|
||||||
|
'members': list(self.members),
|
||||||
|
'metadata': dict(self.metadata),
|
||||||
|
'created_at': self.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TeamMessage:
|
||||||
|
message_id: str
|
||||||
|
team_name: str
|
||||||
|
sender: str
|
||||||
|
text: str
|
||||||
|
recipient: str | None = None
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, payload: dict[str, Any]) -> 'TeamMessage | None':
|
||||||
|
message_id = payload.get('message_id')
|
||||||
|
team_name = payload.get('team_name')
|
||||||
|
sender = payload.get('sender')
|
||||||
|
text = payload.get('text')
|
||||||
|
if not all(isinstance(value, str) and value.strip() for value in (message_id, team_name, sender, text)):
|
||||||
|
return None
|
||||||
|
recipient = payload.get('recipient')
|
||||||
|
if recipient is not None and not isinstance(recipient, str):
|
||||||
|
recipient = None
|
||||||
|
metadata = payload.get('metadata', {})
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
metadata = {}
|
||||||
|
created_at = payload.get('created_at')
|
||||||
|
if not isinstance(created_at, str) or not created_at.strip():
|
||||||
|
created_at = _utc_now()
|
||||||
|
return cls(
|
||||||
|
message_id=message_id.strip(),
|
||||||
|
team_name=team_name.strip(),
|
||||||
|
sender=sender.strip(),
|
||||||
|
text=text,
|
||||||
|
recipient=recipient.strip() if isinstance(recipient, str) and recipient.strip() else None,
|
||||||
|
metadata=dict(metadata),
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'message_id': self.message_id,
|
||||||
|
'team_name': self.team_name,
|
||||||
|
'sender': self.sender,
|
||||||
|
'text': self.text,
|
||||||
|
'recipient': self.recipient,
|
||||||
|
'metadata': dict(self.metadata),
|
||||||
|
'created_at': self.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TeamRuntime:
|
||||||
|
cwd: Path
|
||||||
|
teams: tuple[TeamDefinition, ...] = field(default_factory=tuple)
|
||||||
|
messages: tuple[TeamMessage, ...] = field(default_factory=tuple)
|
||||||
|
manifests: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
state_path: Path = field(default_factory=lambda: DEFAULT_TEAM_STATE_FILE.resolve())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_workspace(
|
||||||
|
cls,
|
||||||
|
cwd: Path,
|
||||||
|
additional_working_directories: tuple[str, ...] = (),
|
||||||
|
) -> 'TeamRuntime':
|
||||||
|
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
|
||||||
|
manifest_teams: list[TeamDefinition] = []
|
||||||
|
for manifest_path in manifest_paths:
|
||||||
|
manifest_teams.extend(_load_teams_from_manifest(manifest_path))
|
||||||
|
state_path = cwd.resolve() / DEFAULT_TEAM_STATE_FILE
|
||||||
|
payload = _load_state_payload(state_path)
|
||||||
|
raw_teams = payload.get('teams')
|
||||||
|
raw_messages = payload.get('messages')
|
||||||
|
if isinstance(raw_teams, list):
|
||||||
|
teams = tuple(
|
||||||
|
team
|
||||||
|
for team in (
|
||||||
|
TeamDefinition.from_dict(item)
|
||||||
|
for item in raw_teams
|
||||||
|
if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
if team is not None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
teams = tuple(manifest_teams)
|
||||||
|
messages = tuple(
|
||||||
|
message
|
||||||
|
for message in (
|
||||||
|
TeamMessage.from_dict(item)
|
||||||
|
for item in raw_messages
|
||||||
|
if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
if message is not None
|
||||||
|
) if isinstance(raw_messages, list) else ()
|
||||||
|
return cls(
|
||||||
|
cwd=cwd.resolve(),
|
||||||
|
teams=teams,
|
||||||
|
messages=messages,
|
||||||
|
manifests=tuple(str(path) for path in manifest_paths),
|
||||||
|
state_path=state_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_team_state(self) -> bool:
|
||||||
|
return bool(self.teams or self.messages or self.manifests)
|
||||||
|
|
||||||
|
def list_teams(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
query: str | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> tuple[TeamDefinition, ...]:
|
||||||
|
teams = self.teams
|
||||||
|
if query:
|
||||||
|
needle = query.lower()
|
||||||
|
teams = tuple(
|
||||||
|
team
|
||||||
|
for team in teams
|
||||||
|
if needle in team.name.lower()
|
||||||
|
or needle in (team.description or '').lower()
|
||||||
|
or any(needle in member.lower() for member in team.members)
|
||||||
|
)
|
||||||
|
teams = tuple(sorted(teams, key=lambda team: team.name.lower()))
|
||||||
|
if limit is not None and limit >= 0:
|
||||||
|
teams = teams[:limit]
|
||||||
|
return teams
|
||||||
|
|
||||||
|
def get_team(self, name: str) -> TeamDefinition | None:
|
||||||
|
needle = name.strip().lower()
|
||||||
|
for team in self.teams:
|
||||||
|
if team.name.lower() == needle:
|
||||||
|
return team
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_team(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
description: str | None = None,
|
||||||
|
members: tuple[str, ...] | list[str] = (),
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> TeamDefinition:
|
||||||
|
normalized = name.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise KeyError('team_name')
|
||||||
|
if self.get_team(normalized) is not None:
|
||||||
|
raise KeyError(normalized)
|
||||||
|
team = TeamDefinition(
|
||||||
|
name=normalized,
|
||||||
|
description=description.strip() if isinstance(description, str) and description.strip() else None,
|
||||||
|
members=tuple(
|
||||||
|
member.strip()
|
||||||
|
for member in members
|
||||||
|
if isinstance(member, str) and member.strip()
|
||||||
|
),
|
||||||
|
metadata=dict(metadata or {}),
|
||||||
|
)
|
||||||
|
self.teams = tuple(sorted((*self.teams, team), key=lambda item: item.name.lower()))
|
||||||
|
self._persist_state()
|
||||||
|
return team
|
||||||
|
|
||||||
|
def delete_team(self, name: str) -> TeamDefinition:
|
||||||
|
team = self.get_team(name)
|
||||||
|
if team is None:
|
||||||
|
raise KeyError(name)
|
||||||
|
self.teams = tuple(existing for existing in self.teams if existing.name != team.name)
|
||||||
|
self.messages = tuple(message for message in self.messages if message.team_name != team.name)
|
||||||
|
self._persist_state()
|
||||||
|
return team
|
||||||
|
|
||||||
|
def send_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
team_name: str,
|
||||||
|
text: str,
|
||||||
|
sender: str,
|
||||||
|
recipient: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> TeamMessage:
|
||||||
|
team = self.get_team(team_name)
|
||||||
|
if team is None:
|
||||||
|
raise KeyError(team_name)
|
||||||
|
message = TeamMessage(
|
||||||
|
message_id=f'msg_{uuid4().hex[:10]}',
|
||||||
|
team_name=team.name,
|
||||||
|
sender=sender.strip(),
|
||||||
|
text=text,
|
||||||
|
recipient=recipient.strip() if isinstance(recipient, str) and recipient.strip() else None,
|
||||||
|
metadata=dict(metadata or {}),
|
||||||
|
)
|
||||||
|
self.messages = (*self.messages, message)
|
||||||
|
self._persist_state()
|
||||||
|
return message
|
||||||
|
|
||||||
|
def render_summary(self) -> str:
|
||||||
|
lines = [
|
||||||
|
f'Configured teams: {len(self.teams)}',
|
||||||
|
f'Message history entries: {len(self.messages)}',
|
||||||
|
f'Team manifests: {len(self.manifests)}',
|
||||||
|
]
|
||||||
|
if self.teams:
|
||||||
|
lines.append('- Teams:')
|
||||||
|
for team in self.list_teams(limit=10):
|
||||||
|
lines.append(f' - {team.name} ({len(team.members)} members)')
|
||||||
|
if len(self.teams) > 10:
|
||||||
|
lines.append(f' - ... plus {len(self.teams) - 10} more')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
def render_team(self, name: str) -> str:
|
||||||
|
team = self.get_team(name)
|
||||||
|
if team is None:
|
||||||
|
raise KeyError(name)
|
||||||
|
lines = ['# Team', '', f'- Name: {team.name}']
|
||||||
|
if team.description:
|
||||||
|
lines.append(f'- Description: {team.description}')
|
||||||
|
lines.append(f'- Members: {len(team.members)}')
|
||||||
|
if team.members:
|
||||||
|
lines.extend(f' - {member}' for member in team.members)
|
||||||
|
recent_messages = [message for message in self.messages if message.team_name == team.name][-5:]
|
||||||
|
if recent_messages:
|
||||||
|
lines.extend(['', '## Recent Messages'])
|
||||||
|
for message in recent_messages:
|
||||||
|
target = f' -> {message.recipient}' if message.recipient else ''
|
||||||
|
lines.append(f'- {message.sender}{target}: {message.text}')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
def render_teams_index(self, *, query: str | None = None, limit: int = 50) -> str:
|
||||||
|
lines = ['# Teams', '']
|
||||||
|
teams = self.list_teams(query=query, limit=limit)
|
||||||
|
if not teams:
|
||||||
|
lines.append('No local teams are configured.')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
for team in teams:
|
||||||
|
details = [team.name]
|
||||||
|
if team.description:
|
||||||
|
details.append(team.description)
|
||||||
|
if team.members:
|
||||||
|
details.append(f'members={",".join(team.members)}')
|
||||||
|
lines.append('- ' + ' ; '.join(details))
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
def render_messages(self, *, team_name: str | None = None, limit: int = 20) -> str:
|
||||||
|
lines = ['# Team Messages', '']
|
||||||
|
messages = list(self.messages)
|
||||||
|
if team_name:
|
||||||
|
team = self.get_team(team_name)
|
||||||
|
if team is None:
|
||||||
|
raise KeyError(team_name)
|
||||||
|
messages = [message for message in messages if message.team_name == team.name]
|
||||||
|
messages = messages[-limit:]
|
||||||
|
if not messages:
|
||||||
|
lines.append('No team messages recorded.')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
for message in messages:
|
||||||
|
target = f' -> {message.recipient}' if message.recipient else ''
|
||||||
|
lines.append(
|
||||||
|
f'- [{message.team_name}] {message.sender}{target}: {message.text}'
|
||||||
|
)
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
def _persist_state(self) -> None:
|
||||||
|
payload = {
|
||||||
|
'teams': [team.to_dict() for team in self.teams],
|
||||||
|
'messages': [message.to_dict() for message in self.messages],
|
||||||
|
}
|
||||||
|
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.state_path.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=True, indent=2),
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_manifest_paths(cwd: Path, additional_working_directories: tuple[str, ...]) -> tuple[Path, ...]:
|
||||||
|
candidates = [
|
||||||
|
cwd.resolve() / '.claw-teams.json',
|
||||||
|
cwd.resolve() / '.claw-team.json',
|
||||||
|
cwd.resolve() / '.claude' / 'teams.json',
|
||||||
|
]
|
||||||
|
for raw_path in additional_working_directories:
|
||||||
|
root = Path(raw_path).resolve()
|
||||||
|
candidates.extend(
|
||||||
|
[
|
||||||
|
root / '.claw-teams.json',
|
||||||
|
root / '.claw-team.json',
|
||||||
|
root / '.claude' / 'teams.json',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
discovered: list[Path] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for candidate in candidates:
|
||||||
|
if not candidate.is_file():
|
||||||
|
continue
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
if resolved in seen:
|
||||||
|
continue
|
||||||
|
seen.add(resolved)
|
||||||
|
discovered.append(resolved)
|
||||||
|
return tuple(discovered)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_teams_from_manifest(path: Path) -> list[TeamDefinition]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
teams_payload = payload.get('teams')
|
||||||
|
if not isinstance(teams_payload, list):
|
||||||
|
return []
|
||||||
|
teams: list[TeamDefinition] = []
|
||||||
|
for item in teams_payload:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
team = TeamDefinition.from_dict(item)
|
||||||
|
if team is not None:
|
||||||
|
teams.append(team)
|
||||||
|
return teams
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state_payload(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return payload if isinstance(payload, dict) else {}
|
||||||
@@ -12,9 +12,11 @@ from src.agent_context import (
|
|||||||
clear_context_caches,
|
clear_context_caches,
|
||||||
set_system_prompt_injection,
|
set_system_prompt_injection,
|
||||||
)
|
)
|
||||||
|
from src.ask_user_runtime import AskUserRuntime
|
||||||
from src.plan_runtime import PlanRuntime
|
from src.plan_runtime import PlanRuntime
|
||||||
from src.agent_types import AgentRuntimeConfig
|
from src.agent_types import AgentRuntimeConfig
|
||||||
from src.task_runtime import TaskRuntime
|
from src.task_runtime import TaskRuntime
|
||||||
|
from src.team_runtime import TeamRuntime
|
||||||
|
|
||||||
|
|
||||||
class AgentContextTests(unittest.TestCase):
|
class AgentContextTests(unittest.TestCase):
|
||||||
@@ -151,6 +153,20 @@ class AgentContextTests(unittest.TestCase):
|
|||||||
self.assertIn('Configured account profiles: 1', snapshot.user_context['accountRuntime'])
|
self.assertIn('Configured account profiles: 1', snapshot.user_context['accountRuntime'])
|
||||||
self.assertIn('dev@example.com', snapshot.user_context['accountRuntime'])
|
self.assertIn('dev@example.com', snapshot.user_context['accountRuntime'])
|
||||||
|
|
||||||
|
def test_user_context_loads_ask_user_runtime_summary(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir) / 'repo'
|
||||||
|
workspace.mkdir(parents=True)
|
||||||
|
(workspace / '.claw-ask-user.json').write_text(
|
||||||
|
'{"answers":[{"question":"Approve deploy?","answer":"yes"}]}',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||||
|
|
||||||
|
self.assertIn('askUserRuntime', snapshot.user_context)
|
||||||
|
self.assertIn('Queued answers: 1', snapshot.user_context['askUserRuntime'])
|
||||||
|
|
||||||
def test_user_context_loads_config_runtime_summary(self) -> None:
|
def test_user_context_loads_config_runtime_summary(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
workspace = Path(tmp_dir) / 'repo'
|
workspace = Path(tmp_dir) / 'repo'
|
||||||
@@ -195,6 +211,18 @@ class AgentContextTests(unittest.TestCase):
|
|||||||
self.assertIn('planRuntime', snapshot.user_context)
|
self.assertIn('planRuntime', snapshot.user_context)
|
||||||
self.assertIn('Total plan steps: 1', snapshot.user_context['planRuntime'])
|
self.assertIn('Total plan steps: 1', snapshot.user_context['planRuntime'])
|
||||||
|
|
||||||
|
def test_user_context_loads_team_runtime_summary(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir) / 'repo'
|
||||||
|
workspace.mkdir(parents=True)
|
||||||
|
runtime = TeamRuntime.from_workspace(workspace)
|
||||||
|
runtime.create_team('reviewers', members=['alice', 'bob'])
|
||||||
|
|
||||||
|
snapshot = build_context_snapshot(AgentRuntimeConfig(cwd=workspace))
|
||||||
|
|
||||||
|
self.assertIn('teamRuntime', snapshot.user_context)
|
||||||
|
self.assertIn('Configured teams: 1', snapshot.user_context['teamRuntime'])
|
||||||
|
|
||||||
@unittest.skipIf(shutil.which('git') is None, 'git is required for git context tests')
|
@unittest.skipIf(shutil.which('git') is None, 'git is required for git context tests')
|
||||||
def test_git_status_snapshot_contains_branch_and_status(self) -> None:
|
def test_git_status_snapshot_contains_branch_and_status(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
|||||||
@@ -186,6 +186,25 @@ class AgentPromptingTests(unittest.TestCase):
|
|||||||
prompt = render_system_prompt(parts)
|
prompt = render_system_prompt(parts)
|
||||||
self.assertIn('# Account', prompt)
|
self.assertIn('# Account', prompt)
|
||||||
|
|
||||||
|
def test_prompt_builder_mentions_ask_user_when_runtime_is_loaded(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir)
|
||||||
|
(workspace / '.claw-ask-user.json').write_text(
|
||||||
|
'{"answers":[{"question":"Approve deploy?","answer":"yes"}]}',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||||
|
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||||
|
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||||
|
parts = build_system_prompt_parts(
|
||||||
|
prompt_context=prompt_context,
|
||||||
|
runtime_config=runtime_config,
|
||||||
|
tools=default_tool_registry(),
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = render_system_prompt(parts)
|
||||||
|
self.assertIn('# Ask User', prompt)
|
||||||
|
|
||||||
def test_prompt_builder_mentions_config_when_runtime_is_loaded(self) -> None:
|
def test_prompt_builder_mentions_config_when_runtime_is_loaded(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
workspace = Path(tmp_dir)
|
workspace = Path(tmp_dir)
|
||||||
@@ -224,6 +243,25 @@ class AgentPromptingTests(unittest.TestCase):
|
|||||||
prompt = render_system_prompt(parts)
|
prompt = render_system_prompt(parts)
|
||||||
self.assertIn('# Tasks', prompt)
|
self.assertIn('# Tasks', prompt)
|
||||||
|
|
||||||
|
def test_prompt_builder_mentions_teams_when_runtime_is_loaded(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir)
|
||||||
|
(workspace / '.claw-teams.json').write_text(
|
||||||
|
'{"teams":[{"name":"reviewers","members":["alice","bob"]}]}',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
runtime_config = AgentRuntimeConfig(cwd=workspace)
|
||||||
|
model_config = ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct')
|
||||||
|
prompt_context = build_prompt_context(runtime_config, model_config)
|
||||||
|
parts = build_system_prompt_parts(
|
||||||
|
prompt_context=prompt_context,
|
||||||
|
runtime_config=runtime_config,
|
||||||
|
tools=default_tool_registry(),
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = render_system_prompt(parts)
|
||||||
|
self.assertIn('# Teams', prompt)
|
||||||
|
|
||||||
def test_prompt_builder_mentions_planning_when_runtime_is_loaded(self) -> None:
|
def test_prompt_builder_mentions_planning_when_runtime_is_loaded(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
workspace = Path(tmp_dir)
|
workspace = Path(tmp_dir)
|
||||||
|
|||||||
@@ -246,6 +246,43 @@ class AgentSlashCommandTests(unittest.TestCase):
|
|||||||
self.assertIn('profile=local', login_result.final_output)
|
self.assertIn('profile=local', login_result.final_output)
|
||||||
self.assertIn('logged_in=False', logout_result.final_output)
|
self.assertIn('logged_in=False', logout_result.final_output)
|
||||||
|
|
||||||
|
def test_ask_commands_render_local_ask_user_runtime(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir)
|
||||||
|
(workspace / '.claw-ask-user.json').write_text(
|
||||||
|
'{"answers":[{"question":"Approve deploy?","answer":"yes"}]}',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
agent = LocalCodingAgent(
|
||||||
|
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||||
|
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||||
|
)
|
||||||
|
status_result = agent.run('/ask')
|
||||||
|
history_result = agent.run('/ask history')
|
||||||
|
self.assertIn('# Ask User', status_result.final_output)
|
||||||
|
self.assertIn('Queued answers: 1', status_result.final_output)
|
||||||
|
self.assertIn('# Ask User History', history_result.final_output)
|
||||||
|
|
||||||
|
def test_team_commands_render_local_team_runtime(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir)
|
||||||
|
(workspace / '.claw-teams.json').write_text(
|
||||||
|
'{"teams":[{"name":"reviewers","members":["alice","bob"]}]}',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
agent = LocalCodingAgent(
|
||||||
|
model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||||
|
runtime_config=AgentRuntimeConfig(cwd=workspace),
|
||||||
|
)
|
||||||
|
teams_result = agent.run('/teams')
|
||||||
|
team_result = agent.run('/team reviewers')
|
||||||
|
messages_result = agent.run('/messages')
|
||||||
|
self.assertIn('# Teams', teams_result.final_output)
|
||||||
|
self.assertIn('reviewers', teams_result.final_output)
|
||||||
|
self.assertIn('# Team', team_result.final_output)
|
||||||
|
self.assertIn('alice', team_result.final_output)
|
||||||
|
self.assertIn('# Team Messages', messages_result.final_output)
|
||||||
|
|
||||||
def test_config_commands_render_local_reports(self) -> None:
|
def test_config_commands_render_local_reports(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
workspace = Path(tmp_dir)
|
workspace = Path(tmp_dir)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.ask_user_runtime import AskUserRuntime
|
||||||
|
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||||
|
from src.agent_types import AgentRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AskUserRuntimeTests(unittest.TestCase):
|
||||||
|
def test_ask_user_runtime_consumes_queued_answers(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir)
|
||||||
|
(workspace / '.claw-ask-user.json').write_text(
|
||||||
|
'{"answers":[{"question":"Approve deploy?","answer":"yes"}]}',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
runtime = AskUserRuntime.from_workspace(workspace)
|
||||||
|
response = runtime.answer(question='Approve deploy?')
|
||||||
|
restored = AskUserRuntime.from_workspace(workspace)
|
||||||
|
|
||||||
|
self.assertEqual(response.answer, 'yes')
|
||||||
|
self.assertEqual(response.source, 'queued')
|
||||||
|
self.assertEqual(len(restored.queued_answers), 0)
|
||||||
|
self.assertEqual(len(restored.history), 1)
|
||||||
|
|
||||||
|
def test_ask_user_tool_executes_against_runtime(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir)
|
||||||
|
(workspace / '.claw-ask-user.json').write_text(
|
||||||
|
'{"answers":[{"question":"Choose mode","answer":"safe"}]}',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
runtime = AskUserRuntime.from_workspace(workspace)
|
||||||
|
context = build_tool_context(
|
||||||
|
AgentRuntimeConfig(cwd=workspace),
|
||||||
|
ask_user_runtime=runtime,
|
||||||
|
)
|
||||||
|
result = execute_tool(
|
||||||
|
default_tool_registry(),
|
||||||
|
'ask_user_question',
|
||||||
|
{
|
||||||
|
'question': 'Choose mode',
|
||||||
|
'choices': ['safe', 'fast'],
|
||||||
|
'allow_free_text': False,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
self.assertIn('# Ask User', result.content)
|
||||||
|
self.assertIn('safe', result.content)
|
||||||
|
self.assertEqual(result.metadata.get('action'), 'ask_user_question')
|
||||||
@@ -5,7 +5,7 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||||
from src.agent_types import AgentRuntimeConfig
|
from src.agent_types import AgentPermissions, AgentRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
class ExtendedToolTests(unittest.TestCase):
|
class ExtendedToolTests(unittest.TestCase):
|
||||||
@@ -65,3 +65,39 @@ class ExtendedToolTests(unittest.TestCase):
|
|||||||
self.assertTrue(result.ok)
|
self.assertTrue(result.ok)
|
||||||
self.assertIn('slept for', result.content)
|
self.assertIn('slept for', result.content)
|
||||||
self.assertEqual(result.metadata.get('action'), 'sleep')
|
self.assertEqual(result.metadata.get('action'), 'sleep')
|
||||||
|
|
||||||
|
def test_notebook_edit_updates_ipynb_cell(self) -> None:
|
||||||
|
registry = default_tool_registry()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir)
|
||||||
|
notebook = workspace / 'demo.ipynb'
|
||||||
|
notebook.write_text(
|
||||||
|
'{\n'
|
||||||
|
' "cells": [\n'
|
||||||
|
' {"cell_type": "code", "metadata": {}, "source": ["print(1)\\n"], "outputs": [], "execution_count": null}\n'
|
||||||
|
' ],\n'
|
||||||
|
' "metadata": {},\n'
|
||||||
|
' "nbformat": 4,\n'
|
||||||
|
' "nbformat_minor": 5\n'
|
||||||
|
'}\n',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
context = build_tool_context(
|
||||||
|
AgentRuntimeConfig(
|
||||||
|
cwd=workspace,
|
||||||
|
permissions=AgentPermissions(allow_file_write=True),
|
||||||
|
),
|
||||||
|
tool_registry=registry,
|
||||||
|
)
|
||||||
|
result = execute_tool(
|
||||||
|
registry,
|
||||||
|
'notebook_edit',
|
||||||
|
{'path': 'demo.ipynb', 'cell_index': 0, 'source': 'print(2)\n'},
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
updated = notebook.read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
self.assertIn('updated notebook cell 0', result.content)
|
||||||
|
self.assertIn('print(2)', updated)
|
||||||
|
self.assertEqual(result.metadata.get('action'), 'notebook_edit')
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ class MainCliTests(unittest.TestCase):
|
|||||||
self.assertEqual(args.command, 'account-profiles')
|
self.assertEqual(args.command, 'account-profiles')
|
||||||
self.assertEqual(args.cwd, '.')
|
self.assertEqual(args.cwd, '.')
|
||||||
|
|
||||||
|
def test_parser_accepts_ask_user_runtime_commands(self) -> None:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(['ask-history', '--cwd', '.'])
|
||||||
|
self.assertEqual(args.command, 'ask-history')
|
||||||
|
self.assertEqual(args.cwd, '.')
|
||||||
|
|
||||||
def test_parser_accepts_search_runtime_commands(self) -> None:
|
def test_parser_accepts_search_runtime_commands(self) -> None:
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(['search', 'repo query', '--cwd', '.', '--provider', 'local-search'])
|
args = parser.parse_args(['search', 'repo query', '--cwd', '.', '--provider', 'local-search'])
|
||||||
@@ -167,3 +173,11 @@ class MainCliTests(unittest.TestCase):
|
|||||||
self.assertEqual(args.command, 'config-get')
|
self.assertEqual(args.command, 'config-get')
|
||||||
self.assertEqual(args.key_path, 'review.mode')
|
self.assertEqual(args.key_path, 'review.mode')
|
||||||
self.assertEqual(args.cwd, '.')
|
self.assertEqual(args.cwd, '.')
|
||||||
|
|
||||||
|
def test_parser_accepts_team_runtime_commands(self) -> None:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(['team-create', 'reviewers', '--member', 'alice', '--cwd', '.'])
|
||||||
|
self.assertEqual(args.command, 'team-create')
|
||||||
|
self.assertEqual(args.team_name, 'reviewers')
|
||||||
|
self.assertEqual(args.member, ['alice'])
|
||||||
|
self.assertEqual(args.cwd, '.')
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||||
|
from src.agent_types import AgentPermissions, AgentRuntimeConfig
|
||||||
|
from src.team_runtime import TeamRuntime
|
||||||
|
|
||||||
|
|
||||||
|
class TeamRuntimeTests(unittest.TestCase):
|
||||||
|
def test_team_runtime_persists_team_and_messages(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir)
|
||||||
|
runtime = TeamRuntime.from_workspace(workspace)
|
||||||
|
runtime.create_team('reviewers', members=['alice', 'bob'])
|
||||||
|
runtime.send_message(
|
||||||
|
team_name='reviewers',
|
||||||
|
text='Please review the patch.',
|
||||||
|
sender='agent',
|
||||||
|
recipient='alice',
|
||||||
|
)
|
||||||
|
restored = TeamRuntime.from_workspace(workspace)
|
||||||
|
|
||||||
|
self.assertEqual(len(restored.teams), 1)
|
||||||
|
self.assertEqual(restored.teams[0].name, 'reviewers')
|
||||||
|
self.assertEqual(len(restored.messages), 1)
|
||||||
|
self.assertIn('Please review the patch.', restored.render_messages())
|
||||||
|
|
||||||
|
def test_team_tools_execute_against_runtime(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir)
|
||||||
|
runtime = TeamRuntime.from_workspace(workspace)
|
||||||
|
context = build_tool_context(
|
||||||
|
AgentRuntimeConfig(
|
||||||
|
cwd=workspace,
|
||||||
|
permissions=AgentPermissions(allow_file_write=True),
|
||||||
|
),
|
||||||
|
team_runtime=runtime,
|
||||||
|
)
|
||||||
|
create_result = execute_tool(
|
||||||
|
default_tool_registry(),
|
||||||
|
'team_create',
|
||||||
|
{'team_name': 'reviewers', 'members': ['alice', 'bob']},
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
send_result = execute_tool(
|
||||||
|
default_tool_registry(),
|
||||||
|
'send_message',
|
||||||
|
{
|
||||||
|
'team_name': 'reviewers',
|
||||||
|
'message': 'Check notebook changes',
|
||||||
|
'sender': 'agent',
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
list_result = execute_tool(
|
||||||
|
default_tool_registry(),
|
||||||
|
'team_list',
|
||||||
|
{},
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
message_result = execute_tool(
|
||||||
|
default_tool_registry(),
|
||||||
|
'team_messages',
|
||||||
|
{'team_name': 'reviewers'},
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(create_result.ok)
|
||||||
|
self.assertEqual(create_result.metadata.get('action'), 'team_create')
|
||||||
|
self.assertTrue(send_result.ok)
|
||||||
|
self.assertEqual(send_result.metadata.get('action'), 'send_message')
|
||||||
|
self.assertIn('reviewers', list_result.content)
|
||||||
|
self.assertIn('Check notebook changes', message_result.content)
|
||||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
from benchmarks.run_terminal_bench_local import (
|
from benchmarks.run_terminal_bench_local import (
|
||||||
TerminalBenchTask,
|
TerminalBenchTask,
|
||||||
build_host_agent_command,
|
build_host_agent_command,
|
||||||
|
build_verifier_exec_command,
|
||||||
discover_tasks,
|
discover_tasks,
|
||||||
filter_tasks,
|
filter_tasks,
|
||||||
parse_dockerfile_workdir,
|
parse_dockerfile_workdir,
|
||||||
@@ -129,6 +130,57 @@ docker_image = "example/other:latest"
|
|||||||
self.assertIn("instruction=$(cat", cmd)
|
self.assertIn("instruction=$(cat", cmd)
|
||||||
self.assertNotIn("claw-code-agent agent", cmd)
|
self.assertNotIn("claw-code-agent agent", cmd)
|
||||||
|
|
||||||
|
def test_build_verifier_command_fakeroot_adds_flags(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
root = Path(tmp_dir)
|
||||||
|
workspace_dir = root / "workspace"
|
||||||
|
verifier_logs_dir = root / "verifier"
|
||||||
|
task_dir = root / "task"
|
||||||
|
tests_dir = task_dir / "tests"
|
||||||
|
workspace_dir.mkdir()
|
||||||
|
verifier_logs_dir.mkdir()
|
||||||
|
tests_dir.mkdir(parents=True)
|
||||||
|
image_path = root / "image.sif"
|
||||||
|
image_path.write_text("fake", encoding="utf-8")
|
||||||
|
task = TerminalBenchTask(
|
||||||
|
task_dir=task_dir,
|
||||||
|
name="terminal-bench/demo",
|
||||||
|
short_name="demo",
|
||||||
|
instruction="solve it",
|
||||||
|
docker_image="example/demo:latest",
|
||||||
|
agent_timeout_sec=30.0,
|
||||||
|
verifier_timeout_sec=30.0,
|
||||||
|
workdir="/workspace",
|
||||||
|
has_docker_compose=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd_no_fakeroot = build_verifier_exec_command(
|
||||||
|
task=task,
|
||||||
|
image_path=image_path,
|
||||||
|
workspace_dir=workspace_dir,
|
||||||
|
task_dir=task_dir,
|
||||||
|
verifier_logs_dir=verifier_logs_dir,
|
||||||
|
env={},
|
||||||
|
fakeroot=False,
|
||||||
|
)
|
||||||
|
self.assertNotIn("--fakeroot", cmd_no_fakeroot)
|
||||||
|
self.assertNotIn("--writable-tmpfs", cmd_no_fakeroot)
|
||||||
|
|
||||||
|
cmd_fakeroot = build_verifier_exec_command(
|
||||||
|
task=task,
|
||||||
|
image_path=image_path,
|
||||||
|
workspace_dir=workspace_dir,
|
||||||
|
task_dir=task_dir,
|
||||||
|
verifier_logs_dir=verifier_logs_dir,
|
||||||
|
env={},
|
||||||
|
fakeroot=True,
|
||||||
|
)
|
||||||
|
self.assertIn("--fakeroot", cmd_fakeroot)
|
||||||
|
self.assertIn("--writable-tmpfs", cmd_fakeroot)
|
||||||
|
self.assertIn("--contain", cmd_fakeroot)
|
||||||
|
self.assertIn("TMPDIR", cmd_fakeroot)
|
||||||
|
self.assertIn("CURL_CA_BUNDLE", cmd_fakeroot)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user