Merge branch 'dev/rig-migration' of gitlab.com:samnasbo/shared/galaxy
This commit is contained in:
@@ -40,39 +40,54 @@ Environment variables:
|
|||||||
- Set `GALAXY_BEDROCK_DIAGNOSTICS=1` to enable Bedrock diagnostic output, including:
|
- Set `GALAXY_BEDROCK_DIAGNOSTICS=1` to enable Bedrock diagnostic output, including:
|
||||||
- `Error_<timestamp>.txt` snapshot files written to the repository root on request/stream failures (includes the serialized Bedrock context window, tool definitions, protobuf request debug payload, captured Bedrock diagnostic lines, and log tails)
|
- `Error_<timestamp>.txt` snapshot files written to the repository root on request/stream failures (includes the serialized Bedrock context window, tool definitions, protobuf request debug payload, captured Bedrock diagnostic lines, and log tails)
|
||||||
- Per-event Bedrock diagnostic logs written to `bedrock-diagnostics.log` in the active Warp log directory
|
- Per-event Bedrock diagnostic logs written to `bedrock-diagnostics.log` in the active Warp log directory
|
||||||
|
- Set `GALAXY_TOOL_DIAGNOSTICS=1` to enable verbose local tool queue/execution debug logs and cancellation backtraces. These diagnostics are disabled during routine operation.
|
||||||
|
|
||||||
### AI Provider Architecture
|
### AI Provider Architecture
|
||||||
|
|
||||||
Galaxy supports multiple AI backends via a **provider dispatch pattern**. Provider selection
|
Galaxy supports session-owned ACP backends and direct model providers. Direct-provider selection is
|
||||||
is controlled by settings (`ai.openai.enabled` takes priority over `ai.bedrock.enabled`).
|
controlled by settings (`ai.openai.enabled` takes priority over `ai.bedrock.enabled`), but provider
|
||||||
|
configuration never selects lifecycle ownership.
|
||||||
|
|
||||||
```
|
```
|
||||||
Provider dispatch: response_stream.rs → resolve_provider_config() → ProviderConfig enum
|
Direct provider: controller.rs → prepare_provider_run() → ProviderRunCoordinator
|
||||||
↓ Bedrock ↓ OpenAI
|
↓ one model call per turn
|
||||||
bedrock/translator.rs openai/translator.rs
|
AgentRuntime implementation
|
||||||
|
↓ tool batch
|
||||||
|
correlated action execution
|
||||||
|
↓ committed results
|
||||||
|
next ProviderRun turn
|
||||||
|
|
||||||
|
ACP: controller.rs → ResponseStream → acp_output_stream (session-owned lifecycle)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Shared types** in `app/src/ai/provider/`:
|
**Durable direct-provider run**:
|
||||||
|
- `crates/galaxy_agent_core/src/provider_run.rs` — Serializable `ProviderRun` state machine, run/epoch identity, bounded model retries, ordered tool batches, cancellation, and terminal outcomes
|
||||||
|
- `app/src/ai/runtime/provider_run_coordinator.rs` — Drives one-turn `AgentRuntime` calls, validates model events, projects output, and commits exact tool lifecycle events
|
||||||
|
- `app/src/ai/runtime/rig.rs` — Builds base/CLI request profiles and resolves the configured one-turn runtime; it does not own follow-through
|
||||||
|
- `app/src/ai/runtime/rig_request.rs` — Converts controller request state into provider-neutral `TurnRequest` history, tools, prompts, and MCP aliases
|
||||||
|
- `app/src/ai/runtime/event_translator.rs` — Projects provider-neutral runtime events into Warp response events for UI/history compatibility
|
||||||
|
- `app/src/ai/blocklist/controller.rs` — Retains active runs, correlates actions by `(conversation_id, run_id, epoch, call_id)`, monitors commands, persists checkpoints, and restores interrupted runs
|
||||||
|
- `app/src/ai/blocklist/controller/response_stream.rs` — Owns ACP transport and shared UI/history projection only; it must not drive direct-provider retries or follow-up turns
|
||||||
|
|
||||||
|
**Shared provider types** in `app/src/ai/provider/`:
|
||||||
- `types.rs` — `ConversationMessage`, `MessageRole`, `MessageContent`, `ContentPart`, `ToolDefinition`
|
- `types.rs` — `ConversationMessage`, `MessageRole`, `MessageContent`, `ContentPart`, `ToolDefinition`
|
||||||
- `mod.rs` — `ProviderConfig` enum (Bedrock | OpenAI | None)
|
- `mod.rs` — `ProviderConfig` enum (Bedrock | OpenAI | None)
|
||||||
|
|
||||||
**Bedrock provider** in `app/src/ai/bedrock/`:
|
**Bedrock provider** in `app/src/ai/bedrock/`:
|
||||||
- `translator.rs` — Orchestrator: takes `api::Request` + config, returns `ResponseStream`
|
- `runtime.rs` — Native one-call `AgentRuntime` over `ConverseStream`, including cancellation, reasoning signatures, token usage, and stop/error classification
|
||||||
- `request_translator.rs` — Converts Warp proto → Bedrock SDK types (messages, system prompt, tools, sanitization)
|
- `request_translator.rs` — Shared Bedrock message sanitization and tool definitions
|
||||||
- `response_translator.rs` — Converts Bedrock stream events → Warp proto `ResponseEvent`s
|
- `response_translator.rs` — Compatibility conversion helpers used by tests and background flows
|
||||||
- `convert.rs` — Re-exports shared types + Bedrock SDK type builders
|
- `convert.rs` — Bedrock request construction and prompt-caching behavior
|
||||||
- `client.rs` — AWS SDK client construction and `converse_stream` call
|
- `client.rs` — AWS SDK client construction, runtime creation, and independent background streaming calls
|
||||||
- `models.rs` — Model registry and cross-region inference prefix logic
|
- `models.rs` — Model registry and cross-region inference prefix logic
|
||||||
- `discovery.rs` — AWS profile listing and model discovery (STS identity check + ListFoundationModels)
|
- `discovery.rs` — AWS profile listing and model discovery (STS identity check + ListFoundationModels)
|
||||||
- `diagnostic.rs` — Debug logging (enabled via `GALAXY_BEDROCK_DIAGNOSTICS=1`)
|
- `diagnostic.rs` — Debug logging (enabled via `GALAXY_BEDROCK_DIAGNOSTICS=1`)
|
||||||
- `external_config.rs` — Fallback config from Claude Code/OpenCode settings
|
- `external_config.rs` — Fallback config from Claude Code/OpenCode settings
|
||||||
|
|
||||||
**OpenAI/LiteLLM provider** in `app/src/ai/openai/`:
|
**OpenAI-compatible providers**:
|
||||||
- `translator.rs` — Orchestrator: same pattern as Bedrock, targets OpenAI chat completions API
|
- Direct turns use one-call runtimes from `galaxy_agent_rig` selected in `app/src/ai/runtime/rig.rs` for OpenAI/LiteLLM, ChatGPT subscription, Anthropic, Gemini, and Vertex AI
|
||||||
- `client.rs` — `reqwest`-based HTTP client for `POST /v1/chat/completions` with streaming
|
- `app/src/ai/openai/request_translator.rs` sanitizes provider-neutral history for OpenAI-compatible APIs
|
||||||
- `convert.rs` — `ConversationMessage` → OpenAI JSON format (system/user/assistant/tool roles, function calling)
|
- `app/src/ai/openai/client.rs`, `convert.rs`, and `response_translator.rs` remain compatibility/background transport helpers, not lifecycle owners
|
||||||
- `request_translator.rs` — OpenAI-specific message sanitization (lighter than Bedrock's strict alternation rules)
|
|
||||||
- `response_translator.rs` — SSE stream parser → Warp proto `ResponseEvent`s
|
|
||||||
|
|
||||||
**Provider settings** (in settings TOML):
|
**Provider settings** (in settings TOML):
|
||||||
- `ai.bedrock.enabled` — Use AWS Bedrock directly (default: true)
|
- `ai.bedrock.enabled` — Use AWS Bedrock directly (default: true)
|
||||||
@@ -117,17 +132,38 @@ context_size = 128000
|
|||||||
- Provider name shown as the description label in the model picker; icon shows OpenAI logo for all OpenAI-compatible providers
|
- Provider name shown as the description label in the model picker; icon shows OpenAI logo for all OpenAI-compatible providers
|
||||||
|
|
||||||
Key invariants:
|
Key invariants:
|
||||||
- Known tools are in `KNOWN_TOOLS` constant in `response_translator.rs`
|
- Every direct-provider `AgentRuntime::start_turn` performs exactly one model call; only `ProviderRun` may schedule another turn or retry
|
||||||
- Tool definitions are built via `tool_definition_for_name()` in `convert_request.rs`; includes `recall_tool_history` for retrieving past tool results
|
- Direct-provider model calls allow 120 seconds for stream startup and 300 seconds between stream events; either timeout is a recoverable transport failure that enters the existing bounded retry lifecycle with the same work identity
|
||||||
- Unknown/hallucinated tool calls are caught in the stream, paired with synthetic error results, and now emit a visible `AgentOutput` text message to the UI
|
- Direct-provider remote telemetry records requested, started, retry-scheduled, and finished model-turn phases with explicit `llm_finished` state; root `provider_run_finished` records distinguish clean completion from failure or cancellation and mark the response stream terminal
|
||||||
- `recall_tool_history` is handled inline in the response translator (synthetic result from `messages_sent`)
|
- `use_rig` and provider selection may choose request/transport details but must never choose lifecycle ownership
|
||||||
- Tool result archive: before progressive summarization drains messages, `ConversationMessage::archive_tool_results()` extracts all tool_use/tool_result pairs into a separate `tool_result_archive` vec. `recall_tool_history` searches both live history + archived results, and supports a `tool_use_id` parameter for exact ID lookup
|
- Direct-provider output may be projected through `ResponseStream`, but provider progress must not depend on response-stream result draining or `AfterStreamFinished`
|
||||||
- Prompt caching uses three cache points: system prompt, conversation history (second-to-last message), tool config
|
- Direct-provider `RequestFileEdits` views must register from streaming output before provider-run completion; preprocessing results must survive delayed view registration, and `NotReady` retries must remain automatic rather than emitting a synthetic user permission decision
|
||||||
|
- A clean direct-provider `ProviderRunOutcome::Completed` explicitly finalizes the conversation as `Success` after terminal output projection, even if earlier turns added tool actions; child-completion waits rely on that status
|
||||||
|
- Provider actions and results must correlate by `(conversation_id, run_id, epoch, call_id)`; stale or duplicate callbacks must not advance a run
|
||||||
|
- Action status/result lookups and archived results are keyed by `(conversation_id, action_id)`; callers must supply the owning conversation and must not fall back to a global action-ID search
|
||||||
|
- Action blocked/executing/finished events carry `conversation_id`; UI subscribers must match it, and CLI shell-control mutations must also match the active block's requested-command action ID
|
||||||
|
- Active provider runs must checkpoint before external work, persist without credentials, validate deserialized run invariants before normalization or runtime construction, normalize unsafe restored states, and reconcile command state before continuing; cancellation intent stays checkpointed until the terminal outcome is projected and `finish_active_provider_run` performs cleanup
|
||||||
|
- A restored `AwaitingModel` checkpoint has an uncertain remote outcome and must terminate as an explicit restore failure rather than replaying the call; known recoverable failures observed in-process retain the bounded model-retry lifecycle
|
||||||
|
- Same-conversation direct-provider follow-ups queue behind the cancelling generation; the old run keeps the active slot until terminal projection and cleanup, queued intent is checkpointed without credentials for restart recovery, and queued-only restore validates provider ownership and terminalizes the abandoned unprepared exchange from its persisted projection/stream identity before starting the successor; the next generation rebuilds provider history after cleanup so it includes the old generation's final committed output, and stale callbacks are ignored by stream identity
|
||||||
|
- Known tools are in `KNOWN_TOOLS` in `response_translator.rs`; definitions are built by `tool_definition_for_name()` in `convert_request.rs`
|
||||||
|
- Direct-provider normal and plan turns must advertise `read_plan`, `create_plan`, and `edit_plan` when the matching document capabilities are enabled; plan-creation requests should call `create_plan` after research rather than only returning prose
|
||||||
|
- Unknown or invalid tool calls receive one correlated synthetic error result and a visible `AgentOutput` message; the durable run owns any continuation
|
||||||
|
- `recall_tool_history` is an inline completed tool batch. `ProviderRun` commits its synthetic result and starts a bounded next turn without routing it through client action execution
|
||||||
|
- `recall_tool_history` excludes earlier calls to itself; archived tool results remain searchable by query or exact `tool_use_id`
|
||||||
|
- Before progressive summarization drains messages, `ConversationMessage::archive_tool_results()` moves tool-use/result pairs into `tool_result_archive`
|
||||||
|
- Bedrock prompt caching uses three cache points: system prompt, second-to-last history message, and tool configuration
|
||||||
- `ensure_tool_results_paired()` enforces Bedrock's invariant that every `tool_use` has a matching `tool_result`
|
- `ensure_tool_results_paired()` enforces Bedrock's invariant that every `tool_use` has a matching `tool_result`
|
||||||
- `inject_input_messages_into_task()` and `extract_user_query_text()` ensure user queries persist for session restore
|
- Progressive summaries are prepended to provider requests as a user/assistant pair; background summarization remains independent of the active provider run
|
||||||
- The stream emits a `UserQuery` proto message at the start of each response for conversation title
|
- Loop prevention in `controller.rs` detects repeated tool failures (3+ identical) and injects a corrective instruction
|
||||||
- Progressive summary (if present) is prepended to the messages array as a user/assistant pair in `translator.rs`
|
- Direct-provider long-running shell follow-ups retain CLI tasks as UI projections while command monitoring and completion stay owned by the same provider run
|
||||||
- Loop prevention guardrail in `controller.rs` detects repeated tool failures (3+ identical) and injects corrective instructions
|
- A direct-provider command completion is only queued when the terminal reports it; the CLI task remains active until the provider run applies that completion at a safe boundary and deactivates it
|
||||||
|
- Provider command ownership is resolved from the active slot or its durable snapshot by block/action identity; completion arriving during restore is persisted into that snapshot and must never fall back to the legacy assessment path
|
||||||
|
- Direct-provider completed-command assessments are hidden, tool-free root-task turns whose output and hidden input survive CLI-task deactivation and restoration
|
||||||
|
- ACP remains a separate session-owned runtime; direct-provider cleanup must not move ACP lifecycle into `ProviderRun`
|
||||||
|
- Orchestrated child conversations are leaf workers: nested `RunAgents` and legacy `StartAgent` calls are rejected before autonomy or permission bypasses, and child requests do not advertise delegation tools
|
||||||
|
- Direct-provider `RunAgents` remains pending until every local child reaches `Success`, `Error`, or `Cancelled`, or is removed/deleted; recoverable `Blocked`, `TransientError`, and `WaitingForEvents` states remain pending, and the hosted 30-second startup timeout must not apply to these completion waits
|
||||||
|
- `StartAgentWaitPolicy` is selected from child execution mode, not parent `run_id`: local children wait for completion and only remote/hosted children use startup acknowledgement
|
||||||
|
- Hosted `RunAgents` startup timeouts detach the exact `StartAgentRequestId`; late launch callbacks must not register the child after the timeout result, while children linked before cancellation remain independently running
|
||||||
|
|
||||||
### Platform Setup
|
### Platform Setup
|
||||||
- `./script/bootstrap` - Platform-specific setup plus common agent skill installation from `skills-lock.json`; prompts for project/global when an install or update is needed unless a target flag or environment override is provided.
|
- `./script/bootstrap` - Platform-specific setup plus common agent skill installation from `skills-lock.json`; prompts for project/global when an install or update is needed unless a target flag or environment override is provided.
|
||||||
@@ -335,9 +371,9 @@ Behavior:
|
|||||||
|
|
||||||
### Appearance Settings Notes
|
### Appearance Settings Notes
|
||||||
|
|
||||||
- Samsung-inspired built-in themes are available as `SamsungDark` and `SamsungLight`.
|
- Galaxy's built-in brand themes are available as `GalaxyDark` and `GalaxyDay`.
|
||||||
- UI font selection is persisted in `appearance.text.ui_font_name` and uses an empty string as the system-default sentinel.
|
- UI font selection is persisted in `appearance.text.ui_font_name` and uses an empty string as the system-default sentinel.
|
||||||
- The one-click Samsung brand preset is implemented in `app/src/settings_view/appearance_page.rs` and applies:
|
- The one-click Galaxy brand preset is implemented in `app/src/settings_view/appearance_page.rs` and applies:
|
||||||
- Samsung dark/light theme mapping
|
- Galaxy Dark/Day system theme mapping
|
||||||
- terminal + AI font defaults
|
- terminal + AI font defaults
|
||||||
- a best-available Samsung-style UI font fallback
|
- the bundled, SIL Open Font License-licensed Roboto UI font
|
||||||
|
|||||||
+67
-192
@@ -1,215 +1,90 @@
|
|||||||
# Contributing to Warp
|
# Contributing to Galaxy
|
||||||
|
|
||||||
Thanks for helping improve Warp! This guide explains how to open issues, propose changes, and get your work reviewed.
|
Thanks for helping improve Galaxy. This guide describes the local-first workflow for reporting
|
||||||
|
issues, developing changes, and preparing a reviewable contribution.
|
||||||
|
|
||||||
> [!TIP]
|
## Before you start
|
||||||
> **Chat with us in Slack.** Connect with other contributors and the Warp team in the [`#oss-contributors`](https://warpcommunity.slack.com/archives/C0B0LM8N4DB) channel — a good place for ad-hoc questions, design discussion, and pairing with maintainers as you work through an issue or PR. New here? [Join the Warp Slack community](https://go.warp.dev/join-preview) first, then hop into `#oss-contributors`.
|
|
||||||
|
|
||||||
## TL;DR
|
- Read [AGENTS.md](AGENTS.md) for the architecture, development commands, and coding conventions.
|
||||||
|
- Search the repository issue tracker before opening a duplicate report.
|
||||||
|
- For a bug, include reproduction steps, expected and actual behavior, platform details, and logs
|
||||||
|
or screenshots that do not contain secrets.
|
||||||
|
- For a larger feature, describe the user problem and agree on the shape of the change before
|
||||||
|
implementing it. Keep product and technical plans under `plans/` or `specs/` as appropriate.
|
||||||
|
- Report security vulnerabilities privately using [SECURITY.md](SECURITY.md). Do not publish
|
||||||
|
exploitable details in an issue or pull request.
|
||||||
|
|
||||||
- Bug fixes are welcome once the report is actionable from the provided details or maintainer triage.
|
## Development setup
|
||||||
- Feature requests must be marked `ready-to-spec` or `ready-to-implement` before PRs are accepted.
|
|
||||||
- Issues marked `warp:reserved-internal` are being handled by the Warp team and are not open for contributor PRs.
|
|
||||||
- Specs are the place where technical and design discussion on larger issues happen.
|
|
||||||
- Oz automatically triages incoming issues and reviews open PRs.
|
|
||||||
- Implementation PRs must include proof of manual testing.
|
|
||||||
|
|
||||||
## How Contributing to Warp Works
|
|
||||||
|
|
||||||
Warp's contribution model is shaped by [Oz](https://oz.warp.dev), an agent that automates parts of triage, spec writing, implementation, and review. Compared with a typical open-source repository, a few things work differently here:
|
|
||||||
|
|
||||||
- **Issues are the starting point for everything.** Discussion, scoping, and design happen on the issue before any PR is opened.
|
|
||||||
- **Feature requests differ from bug fixes:**
|
|
||||||
- Features are gated by readiness labels — `ready-to-spec`, then `ready-to-implement` once the design is settled — that signal when contributors can pick up the work. Discussion alone is not approval to begin work.
|
|
||||||
- Feature work needs a written spec first: feature requests go through a spec PR (a *product spec* + *tech spec* committed under [`specs/`](specs/)) before any code is written.
|
|
||||||
- Bug fixes can go straight to a code PR once the report is reproducible or otherwise actionable; they do not require spec PRs unless the scope or design is unclear.
|
|
||||||
- **Review is largely automated.** When you open a PR, Oz is auto-assigned and produces an initial review. Once Oz approves, it automatically requests a follow-up review from a Warp team subject-matter expert — you do not need to assign human reviewers yourself.
|
|
||||||
|
|
||||||
### Readiness labels
|
|
||||||
|
|
||||||
The Warp team applies one of the following labels when an issue is ready for contribution:
|
|
||||||
|
|
||||||
- **`ready-to-spec`** — The problem is understood but the design is open. Open a spec PR with a *product spec* (`product.md`) and a *tech spec* (`tech.md`) under [`specs/`](specs/) — see [Opening a Spec PR](#opening-a-spec-pr) for what goes in each. This label is **reserved for feature requests**.
|
|
||||||
- **`ready-to-implement`** — The issue is ready for a code PR. For bugs, this means the report is sufficiently reproducible or actionable and the likely fix does not need a spec, mocks, or deeper investigation.
|
|
||||||
- **`needs-mocks`** — Design mocks are required before implementation can begin. Wait for the Warp team to land them.
|
|
||||||
- **`warp:reserved-internal`** — The Warp team is reserving this work for internal implementation or alignment. Do not open a spec or code PR for issues with this label; Oz will reject contributor PRs linked to them with an explanatory comment.
|
|
||||||
|
|
||||||
Anyone can pick up a ready issue — readiness labels are not assignments, and the best implementation wins through normal review. If an issue has been sitting un-triaged or you'd like readiness re-evaluated, mention **@oss-maintainers** in a comment to flag it for the team.
|
|
||||||
|
|
||||||
## Contribution Flow
|
|
||||||
|
|
||||||
Steps owned by you (the contributor) are shown in yellow; steps owned by the Warp team or Oz are shown in blue.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A[File an issue] --> B{Warp team triages}
|
|
||||||
B -- ready-to-spec<br/>(feature requests) --> C[Open spec PR<br/>product.md + tech.md]
|
|
||||||
B -- needs-mocks --> D[Design mocks produced]
|
|
||||||
D --> E[Open code PR]
|
|
||||||
C -- specs approved --> E
|
|
||||||
B -- ready-to-implement<br/>(actionable bugs or settled designs) --> E
|
|
||||||
E --> F[Oz review → SME review → CI → merge]
|
|
||||||
|
|
||||||
classDef contributor fill:#fef3c7,stroke:#b45309,color:#78350f;
|
|
||||||
classDef warpTeam fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
|
|
||||||
class A,C,E contributor;
|
|
||||||
class B,D,F warpTeam;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Filing a Good Issue
|
|
||||||
|
|
||||||
Search [existing issues](https://github.com/warpdotdev/warp/issues) before filing to avoid duplicates. Use the issue templates when filing.
|
|
||||||
|
|
||||||
If you're already running Warp, the fastest way to file is the `/feedback` command — it opens a public GitHub issue with relevant context (logs, environment details) automatically attached.
|
|
||||||
|
|
||||||
### Bug reports
|
|
||||||
|
|
||||||
A good bug report includes:
|
|
||||||
|
|
||||||
- A clear title and a one-paragraph summary of the problem.
|
|
||||||
- Steps to reproduce (with a minimal example where possible).
|
|
||||||
- Expected vs. actual behavior.
|
|
||||||
- Warp version and OS (see `Settings → About`).
|
|
||||||
- Logs, screenshots, or screen recordings when relevant.
|
|
||||||
|
|
||||||
Once an issue is triaged as an actionable bug (by Oz's triage agent or a maintainer), it may be labeled **`ready-to-implement`** so you can pick it up and open a code PR.
|
|
||||||
|
|
||||||
### Feature requests
|
|
||||||
|
|
||||||
A good feature request describes the user-facing problem before any proposed implementation. Include:
|
|
||||||
|
|
||||||
- The user need or pain point, and who experiences it.
|
|
||||||
- The current behavior and why it falls short.
|
|
||||||
- A sketch of the desired behavior or workflow (a short example or mock is helpful but not required).
|
|
||||||
- Any relevant constraints (compatibility, related features, prior art, etc.).
|
|
||||||
|
|
||||||
Feature requests are the path that goes through the spec flow: a maintainer applies **`ready-to-spec`** when the problem is understood and the design is open for contributors. From there, the next step is a spec PR — not a code PR.
|
|
||||||
|
|
||||||
Automated triage may add informational labels (`area:*`, `repro:*`, etc.). Those do not affect readiness.
|
|
||||||
|
|
||||||
## Opening a Spec PR
|
|
||||||
|
|
||||||
Issues labeled `ready-to-spec` need a spec before code can begin. A spec consists of two short documents committed under [`specs/GH<issue-number>/`](specs/):
|
|
||||||
|
|
||||||
- **`product.md`** (the *product spec*) — Defines the desired behavior from the consumer's perspective (the user, an API caller, a CLI user, etc.) and stays out of implementation detail. The core is a numbered list of **testable behavior invariants** covering the happy path, user-visible states, inputs and responses, and edge cases (empty / error / loading, cancellation, offline, permission denied, races, accessibility). Optional sections: problem statement, goals / non-goals, Figma link, open questions.
|
|
||||||
- **`tech.md`** (the *tech spec*) — The implementation plan, grounded in this codebase. Required sections: **Context** (the current system and relevant files with line references), **Proposed changes** (modules touched, new types / APIs / state, data flow, tradeoffs), and **Testing and validation** (how each invariant from the product spec will be verified). Optional: end-to-end flow, Mermaid diagrams, risks, parallelization, follow-ups.
|
|
||||||
|
|
||||||
The spec-writing skills are sourced from [`warpdotdev/common-skills`](https://github.com/warpdotdev/common-skills), not authored directly in this repository. This checkout pins the expected versions in [`skills-lock.json`](skills-lock.json), and the bootstrap scripts can restore them for you:
|
|
||||||
|
|
||||||
- `./script/bootstrap` installs or updates common skills by default and prompts for a project-local or global install target when needed.
|
|
||||||
- `./script/bootstrap --install-common-skills-in-repo` installs the pinned common skills into this checkout's `.agents/skills/`.
|
|
||||||
- `./script/bootstrap --install-common-skills-globally` installs the pinned common skills into `~/.agents/skills/`.
|
|
||||||
- `WARP_COMMON_SKILLS_INSTALL_TARGET=project ./script/bootstrap` and `WARP_COMMON_SKILLS_INSTALL_TARGET=global ./script/bootstrap` select the same targets non-interactively.
|
|
||||||
- `./script/bootstrap --skip-common-skills` leaves common skills untouched if you are managing them separately.
|
|
||||||
|
|
||||||
To open a spec PR:
|
|
||||||
|
|
||||||
1. Add `specs/GH<issue-number>/product.md` and `specs/GH<issue-number>/tech.md`. See [`specs/GH408/`](specs/GH408/), [`specs/GH1063/`](specs/GH1063/), and [`specs/GH1066/`](specs/GH1066/) for examples of well-structured specs, and browse the rest of [`specs/`](specs/) for more. After common skills are installed, the `/write-product-spec` and `/write-tech-spec` skills are available to scaffold these for you.
|
|
||||||
2. Use the PR as the home for product and technical discussion.
|
|
||||||
3. Once the specs are approved, implementation generally continues on the same PR. In rarer cases — for example, if a large spec is merged on its own so the implementation can be broken up — it can move to a linked follow-up PR.
|
|
||||||
|
|
||||||
## Opening a Code PR
|
|
||||||
|
|
||||||
For issues labeled `ready-to-implement`:
|
|
||||||
|
|
||||||
1. Branch from `master`.
|
|
||||||
2. Implement the change and add tests (see [Testing](#testing)).
|
|
||||||
3. Run `./script/presubmit` and fix any failures before pushing.
|
|
||||||
4. Open a PR using the [pull request template](.github/pull_request_template.md) and add a changelog entry (`CHANGELOG-NEW-FEATURE`, `CHANGELOG-IMPROVEMENT`, or `CHANGELOG-BUG-FIX`); omit only for docs-only or refactoring-only changes.
|
|
||||||
5. Keep the PR focused on a single logical change and merge `master` in before the PR enters review.
|
|
||||||
|
|
||||||
You **do not need to manually request reviewers**. Oz is auto-assigned to PRs that target a ready issue and produces an initial review. After Oz approves, it automatically requests a follow-up review from the appropriate Warp team subject-matter expert.
|
|
||||||
|
|
||||||
After you push changes that address Oz's feedback, comment `/oz-review` on the PR to request a re-review — you can do this up to **three times** per PR. If something looks stuck or you need more reviews than that, mention **@oss-maintainers** on the PR to escalate to the team.
|
|
||||||
|
|
||||||
**You must include proof of [manual testing](#manual-testing)**. For small, isolated, and visual changes, you should include **before and after screenshots**. For larger, broad, or interactive changes, you should also include a **narrated screen recording**.
|
|
||||||
|
|
||||||
If a maintainer requests changes to your PR, you will need to request `/oz-review` again and pass it before a re-review can be requested. Oz will request the re-review for you automatically once you pass its reviews.
|
|
||||||
|
|
||||||
### PRs opened without a linked issue
|
|
||||||
|
|
||||||
We require PRs to be linked to an associated issue. This is where problems get scoped, [readiness labels](#readiness-labels) get applied, and some features go through a [spec phase](#opening-a-spec-pr) before any code is written. See the [Contribution Flow](#contribution-flow) for the full picture.
|
|
||||||
|
|
||||||
That said, if you open a PR ahead of the standard issue workflow, here's what we recommend:
|
|
||||||
|
|
||||||
First, **search for a related issue.** Due to the volume of issues we receive, there's often an existing issue for a given feature or bug fix. If you find one, link it in your PR description. Ideally, this issue will have been reviewed by a maintainer with a [readiness label](#readiness-labels) applied. If you do not find a related issue, file an issue describing what your PR resolves. Once a maintainer has reviewed the issue and associated PR, we can apply a readiness label to unblock final checks.
|
|
||||||
|
|
||||||
Then, **ensure your PR passes code review and includes relevant tests** per our [Opening a Code PR guide.](#opening-a-code-pr) If code review passes and relevant tests are present, that's high signal for us to review your work sooner.
|
|
||||||
|
|
||||||
## Using a Coding Agent
|
|
||||||
|
|
||||||
You can use **any coding agent** to implement a contribution — for example, Warp's built-in agent, Claude Code, Codex, Gemini CLI, or others — or no agent at all. This repository ships agent-readable context (skills under [`.agents/skills/`](.agents/skills/), specs under [`specs/`](specs/), and [`AGENTS.md`](AGENTS.md)) that any harness supporting these formats can pick up.
|
|
||||||
|
|
||||||
If you'd rather have an **Oz cloud agent** implement a ready issue for you, mention **@oss-maintainers** on the issue to request it. Approved requests run **for free** on complimentary Oz credits — you don't need to set up your own Oz account or pay for compute.
|
|
||||||
|
|
||||||
While you can use coding agents for implementation, we expect contributors to **collaborate with us personally**. This means that you should not be using agents like OpenClaw to engage in conversation with our team. Our maintainers will always talk to you as a human, so please talk to us as a human as well.
|
|
||||||
|
|
||||||
## Code Review
|
|
||||||
|
|
||||||
All pull requests go through a two-stage review process:
|
|
||||||
|
|
||||||
1. **Oz review** — When you open a PR, [Oz](https://warp.dev/oz) is automatically assigned and produces the first review. Oz checks for correctness, style, test coverage, and alignment with the linked issue and any associated specs.
|
|
||||||
2. **Warp team review** — Only after Oz has **approved** the PR is it routed to a Warp team subject-matter expert for a final human review. PRs that have not yet been approved by Oz will not be assigned to a team member.
|
|
||||||
|
|
||||||
You do not need to manually request reviewers at any stage. After pushing changes that address Oz's feedback, comment `/oz-review` on the PR to request a re-review — you can do this up to **three times** per PR. If something looks stuck or you need additional reviews, mention **@oss-maintainers** on the PR to escalate to the team.
|
|
||||||
|
|
||||||
### Stale PRs with requested changes
|
|
||||||
|
|
||||||
If a review (from Oz or a maintainer) leaves your PR with **changes requested** and it then goes quiet, automation follows up and eventually closes it so the review queue stays current. This applies only to external-contributor PRs with an active requested-changes review.
|
|
||||||
|
|
||||||
- **Reminders** are posted at **7** and **10** days of inactivity, with the **day-10 reminder serving as the final warning**.
|
|
||||||
- The PR is **automatically closed at ~14 days** of inactivity — but only after that final warning, so you always get a heads-up first.
|
|
||||||
- Only **your** activity resets the timer: pushing to your branch (including a force-push) or commenting on the PR. Maintainer comments don't reset it, since the PR is waiting on you.
|
|
||||||
- To keep a PR open, just push updates or reply. A closed PR can be reopened when you're ready to continue (reopen it and push, or ask a maintainer to reopen).
|
|
||||||
- Maintainers can apply the **`no-autoclose`** label to exempt a PR that should stay open (for example, when it's blocked on us).
|
|
||||||
|
|
||||||
## Development Setup
|
|
||||||
|
|
||||||
See [README.md](README.md) and [AGENTS.md](AGENTS.md) for the full engineering guide. Quick start:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./script/bootstrap # platform-specific setup
|
./script/bootstrap
|
||||||
cargo run # build and run Warp
|
cargo run
|
||||||
./script/presubmit # fmt, clippy, and tests
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
The project is a Rust workspace with the main client in `app/`, GalaxyUI in `crates/galaxyui/`,
|
||||||
|
and shared libraries under `crates/`. The default build must work without a Galaxy account or a
|
||||||
|
remote service.
|
||||||
|
|
||||||
Tests are required for most code changes:
|
## Making changes
|
||||||
|
|
||||||
### Manual Testing
|
Keep changes focused and preserve the local-first boundaries:
|
||||||
Manual testing is required for changes that can be manually tested, and almost all changes can be manually tested. For small, isolated, and visual changes, you should include **before and after screenshots**. For larger, broad, or interactive changes, you should also include a **narrated screen recording**.
|
|
||||||
|
|
||||||
You can run the app locally using `./script/run` - see [AGENTS.md](AGENTS.md) for more details on how to get set up.
|
- Model traffic may use only a provider explicitly configured by the user.
|
||||||
|
- Galaxy owns permission prompts and execution for shell, file, and MCP actions.
|
||||||
|
- Local content should use the local repository instead of cloud identity or sync services.
|
||||||
|
- New UI should reuse existing GalaxyUI components, theme tokens, and button themes.
|
||||||
|
- Never log credentials, prompts, command output, or provider responses unless a diagnostic mode
|
||||||
|
explicitly documents that behavior.
|
||||||
|
|
||||||
### Automated Tests
|
For user-facing changes, verify the complete flow manually. Include screenshots for small visual
|
||||||
- **Bug fixes** should include a regression test that would have caught the bug.
|
changes and a short recording for larger interactive flows when practical. For persistence,
|
||||||
- **Algorithmic or non-trivial logic** needs unit tests.
|
provider, or agent-runtime changes, add restart, failure, cancellation, or boundary coverage as
|
||||||
- **User-facing flows** should have end-to-end coverage under [`crates/integration/`](crates/integration/) whenever the behavior can be exercised that way. The bar is high-quality coverage of the changes you ship — with agent-driven development the expectation is more integration tests, not just coverage of P0 paths. If a flow is worth shipping, it's usually worth an integration test.
|
appropriate.
|
||||||
|
|
||||||
Run unit tests with `cargo nextest run`.
|
## Checks
|
||||||
|
|
||||||
## Code Style
|
Run the focused checks for the code you touched, then run the required formatting and lint checks:
|
||||||
|
|
||||||
- `./script/format --check` and `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings` must pass.
|
```bash
|
||||||
- Prefer imports over path qualifiers, inline format args (`println!("{x}")`), and exhaustive `match` over `_` wildcards.
|
./script/format
|
||||||
- See [AGENTS.md](AGENTS.md) for the full style guide, including WarpUI patterns and terminal model locking rules.
|
cargo clippy --workspace --all-targets --all-features --tests -- -D warnings
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
## Commit and Branch Conventions
|
Useful narrower commands include:
|
||||||
|
|
||||||
- Branch names should be prefixed with your handle (e.g. `alice/fix-parser`).
|
```bash
|
||||||
- Commit messages should explain *what* and *why*, not just *what*.
|
cargo check -p galaxy
|
||||||
|
cargo test -p galaxy --lib
|
||||||
|
cargo test -p galaxy_agent_core
|
||||||
|
cargo test -p galaxy_agent_rig
|
||||||
|
```
|
||||||
|
|
||||||
## Code of Conduct
|
If a check cannot run locally, explain why in the change description and include the closest
|
||||||
|
available validation.
|
||||||
|
|
||||||
This project adopts the [Contributor Covenant](https://www.contributor-covenant.org/) (v2.1) as its code of conduct. All contributors and maintainers are expected to follow it in every project space. See [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) for the full text, or report violations to warp-coc at warp.dev.
|
## Pull requests
|
||||||
|
|
||||||
## Reporting Security Issues
|
Use a branch named for the change, keep commits focused, and include:
|
||||||
|
|
||||||
See [`SECURITY.md`](SECURITY.md) for our security disclosure policy and private reporting channels. **Do not open public issues for security vulnerabilities.**
|
1. A concise summary of the user-visible behavior.
|
||||||
|
2. The design or architectural boundary affected.
|
||||||
|
3. Automated checks and manual verification performed.
|
||||||
|
4. Screenshots or recordings for visual and interactive changes.
|
||||||
|
5. Any follow-up work added to the relevant plan or noticed-bugs list.
|
||||||
|
|
||||||
## Getting Help
|
Reviewers should be able to build the branch from a clean checkout and understand why the change
|
||||||
|
belongs in Galaxy. Do not include unrelated formatting or generated-file churn.
|
||||||
|
|
||||||
- Chat with other contributors and the Warp team in [`#oss-contributors`](https://warpcommunity.slack.com/archives/C0B0LM8N4DB) on the [Warp Slack community](https://go.warp.dev/join-preview) (join the workspace first if you're new).
|
## Code of conduct
|
||||||
- Browse the [Warp docs](https://docs.warp.dev/).
|
|
||||||
- Open a [GitHub issue](https://github.com/warpdotdev/warp/issues) for bugs or feature requests.
|
Galaxy follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Please keep issue, review, and
|
||||||
|
community discussions respectful, constructive, and focused on the work.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The Galaxy application and most workspace crates are licensed under
|
||||||
|
[AGPL-3.0-only](LICENSE-AGPL). The GalaxyUI crates are licensed under the
|
||||||
|
[MIT License](LICENSE-MIT). Contributions are accepted under the license applicable to the code
|
||||||
|
they modify. Review the existing file headers and third-party notices before copying code into a
|
||||||
|
different crate.
|
||||||
|
|||||||
Generated
+639
-5
@@ -1025,6 +1025,12 @@ version = "0.7.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "as-any"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "as-raw-xcb-connection"
|
name = "as-raw-xcb-connection"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -1327,7 +1333,7 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tungstenite",
|
"tungstenite 0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1489,6 +1495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
|
checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-sys",
|
"aws-lc-sys",
|
||||||
|
"untrusted 0.7.1",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1531,6 +1538,32 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-sdk-bedrock"
|
||||||
|
version = "1.150.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81186bb96a4e98ff93f7b4336deec0afc1f90ca282099fc5395fcf5de6c0389d"
|
||||||
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
|
"aws-credential-types",
|
||||||
|
"aws-runtime",
|
||||||
|
"aws-smithy-async",
|
||||||
|
"aws-smithy-http",
|
||||||
|
"aws-smithy-json",
|
||||||
|
"aws-smithy-observability",
|
||||||
|
"aws-smithy-runtime",
|
||||||
|
"aws-smithy-runtime-api",
|
||||||
|
"aws-smithy-schema",
|
||||||
|
"aws-smithy-types",
|
||||||
|
"aws-types",
|
||||||
|
"bytes",
|
||||||
|
"fastrand 2.5.0",
|
||||||
|
"http 0.2.12",
|
||||||
|
"http 1.5.0",
|
||||||
|
"regex-lite",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-sdk-bedrockruntime"
|
name = "aws-sdk-bedrockruntime"
|
||||||
version = "1.138.0"
|
version = "1.138.0"
|
||||||
@@ -1739,17 +1772,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
|
checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-smithy-async",
|
"aws-smithy-async",
|
||||||
|
"aws-smithy-protocol-test",
|
||||||
"aws-smithy-runtime-api",
|
"aws-smithy-runtime-api",
|
||||||
"aws-smithy-types",
|
"aws-smithy-types",
|
||||||
|
"bytes",
|
||||||
"h2",
|
"h2",
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
|
"http-body 1.1.0",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
|
"indexmap 2.14.0",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -1776,6 +1815,25 @@ dependencies = [
|
|||||||
"aws-smithy-runtime-api",
|
"aws-smithy-runtime-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-smithy-protocol-test"
|
||||||
|
version = "0.64.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f76511a0e223ce78deb6a78b8afebda99cb737cfbc8a58d96dcb190f012dd40a"
|
||||||
|
dependencies = [
|
||||||
|
"assert-json-diff",
|
||||||
|
"aws-smithy-runtime-api",
|
||||||
|
"base64-simd",
|
||||||
|
"cbor-diag",
|
||||||
|
"ciborium",
|
||||||
|
"http 0.2.12",
|
||||||
|
"pretty_assertions",
|
||||||
|
"regex-lite",
|
||||||
|
"roxmltree 0.14.1",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-smithy-query"
|
name = "aws-smithy-query"
|
||||||
version = "0.62.0"
|
version = "0.62.0"
|
||||||
@@ -2692,6 +2750,25 @@ dependencies = [
|
|||||||
"cipher",
|
"cipher",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbor-diag"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc245b6ecd09b23901a4fbad1ad975701fd5061ceaef6afa93a2d70605a64429"
|
||||||
|
dependencies = [
|
||||||
|
"bs58",
|
||||||
|
"chrono",
|
||||||
|
"data-encoding",
|
||||||
|
"half",
|
||||||
|
"nom 7.1.3",
|
||||||
|
"num-bigint",
|
||||||
|
"num-rational",
|
||||||
|
"num-traits",
|
||||||
|
"separator",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -3373,6 +3450,15 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "convert_case"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-segmentation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -4371,6 +4457,12 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "diff"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "difflib"
|
name = "difflib"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -5558,7 +5650,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "galaxy"
|
name = "galaxy"
|
||||||
version = "2.1.0"
|
version = "3.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"addr",
|
"addr",
|
||||||
"aha-reqwest-eventsource",
|
"aha-reqwest-eventsource",
|
||||||
@@ -5582,6 +5674,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-credential-types",
|
"aws-credential-types",
|
||||||
|
"aws-sdk-bedrock",
|
||||||
"aws-sdk-bedrockruntime",
|
"aws-sdk-bedrockruntime",
|
||||||
"aws-sdk-sts",
|
"aws-sdk-sts",
|
||||||
"aws-smithy-types",
|
"aws-smithy-types",
|
||||||
@@ -5653,6 +5746,8 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"fuzzy_match",
|
"fuzzy_match",
|
||||||
"galaxy_acp",
|
"galaxy_acp",
|
||||||
|
"galaxy_agent_core",
|
||||||
|
"galaxy_agent_rig",
|
||||||
"galaxy_cli",
|
"galaxy_cli",
|
||||||
"galaxy_completer",
|
"galaxy_completer",
|
||||||
"galaxy_core",
|
"galaxy_core",
|
||||||
@@ -5855,7 +5950,11 @@ dependencies = [
|
|||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"async-io",
|
"async-io",
|
||||||
|
"async-stream",
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
"futures",
|
"futures",
|
||||||
|
"galaxy_agent_core",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -5863,6 +5962,37 @@ dependencies = [
|
|||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "galaxy_agent_core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel",
|
||||||
|
"async-trait",
|
||||||
|
"futures",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "galaxy_agent_rig"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-stream",
|
||||||
|
"async-trait",
|
||||||
|
"aws-sdk-bedrockruntime",
|
||||||
|
"aws-smithy-http-client",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"futures",
|
||||||
|
"galaxy_agent_core",
|
||||||
|
"rig-bedrock",
|
||||||
|
"rig-core",
|
||||||
|
"rig-vertexai",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "galaxy_cli"
|
name = "galaxy_cli"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
@@ -7102,6 +7232,241 @@ dependencies = [
|
|||||||
"gl_generator",
|
"gl_generator",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-cloud-aiplatform-v1"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c4b46c4e50f6a09b85dae39be560c263a20ec06c2b0d5a96d65423936bf238f"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"google-cloud-api",
|
||||||
|
"google-cloud-gax",
|
||||||
|
"google-cloud-gax-internal",
|
||||||
|
"google-cloud-iam-v1",
|
||||||
|
"google-cloud-location",
|
||||||
|
"google-cloud-longrunning",
|
||||||
|
"google-cloud-lro",
|
||||||
|
"google-cloud-rpc",
|
||||||
|
"google-cloud-type",
|
||||||
|
"google-cloud-wkt",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_with 3.21.0",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-cloud-api"
|
||||||
|
version = "1.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19dd5722ba4d24fbc19f6a44b88c335852c9a98d058bc0d6073c9a730c026cad"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"google-cloud-wkt",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_with 3.21.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-cloud-auth"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f54aab44c16b8463ae11b165a87c3d484780231f157bb1ed65843d591beb5abd"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"aws-lc-rs",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"google-cloud-gax",
|
||||||
|
"hex",
|
||||||
|
"hmac 0.13.0",
|
||||||
|
"http 1.5.0",
|
||||||
|
"jsonwebtoken",
|
||||||
|
"reqwest 0.13.4",
|
||||||
|
"rustc_version",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-cloud-gax"
|
||||||
|
version = "1.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9a46dd0fd026bbc4a5d84e6ab0c941cee6e3b057976a0bb107fdb5238ce598f"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures",
|
||||||
|
"google-cloud-rpc",
|
||||||
|
"google-cloud-wkt",
|
||||||
|
"http 1.5.0",
|
||||||
|
"pin-project",
|
||||||
|
"rand 0.10.2",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-cloud-gax-internal"
|
||||||
|
version = "0.7.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fb04c54317ace06d489213f761797240b3046142a9b7ce6b9a82a9d134e193d1"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures",
|
||||||
|
"google-cloud-auth",
|
||||||
|
"google-cloud-gax",
|
||||||
|
"google-cloud-rpc",
|
||||||
|
"google-cloud-wkt",
|
||||||
|
"h2",
|
||||||
|
"http 1.5.0",
|
||||||
|
"http-body 1.1.0",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"lazy_static",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-semantic-conventions",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project",
|
||||||
|
"prost",
|
||||||
|
"prost-types",
|
||||||
|
"reqwest 0.13.4",
|
||||||
|
"rustc_version",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tonic",
|
||||||
|
"tonic-prost",
|
||||||
|
"tower",
|
||||||
|
"tracing",
|
||||||
|
"tracing-opentelemetry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-cloud-iam-v1"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34cdf5acc7ef946ee2db7a7f62bd436d8395a6543b4beef110cdc061fcf578bb"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"google-cloud-gax",
|
||||||
|
"google-cloud-gax-internal",
|
||||||
|
"google-cloud-type",
|
||||||
|
"google-cloud-wkt",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_with 3.21.0",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-cloud-location"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "280d5acdba8fcb1232c0719ed788d85b7e362b82cbb425b7050d3ce46f075ede"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"google-cloud-gax",
|
||||||
|
"google-cloud-gax-internal",
|
||||||
|
"google-cloud-wkt",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_with 3.21.0",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-cloud-longrunning"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e6ce05df0aea2c08472983ce2bbbed9483cbb637b89ff69a7c4ef94371fe4f2"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"google-cloud-gax",
|
||||||
|
"google-cloud-gax-internal",
|
||||||
|
"google-cloud-rpc",
|
||||||
|
"google-cloud-wkt",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_with 3.21.0",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-cloud-lro"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd7cca2b991d619525d72a170ca7f413cb520872702442da22ac9af650a8e786"
|
||||||
|
dependencies = [
|
||||||
|
"google-cloud-gax",
|
||||||
|
"google-cloud-gax-internal",
|
||||||
|
"google-cloud-longrunning",
|
||||||
|
"google-cloud-rpc",
|
||||||
|
"google-cloud-wkt",
|
||||||
|
"serde",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-cloud-rpc"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e2162c08a89118130979ba261080e960e44cdcb2d6e2ab8ca9b1da245285d353"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"google-cloud-wkt",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_with 3.21.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-cloud-type"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63acc3a92a85f96bab021c3a3e29b53bbacc97651e1b524d4c2991960a63eb82"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"google-cloud-wkt",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_with 3.21.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-cloud-wkt"
|
||||||
|
version = "1.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7fccf98cfd5481a5f5a285181ab0c62123d7d47cd2bb7299448440649349e4e7"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_with 3.21.0",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gpu-allocator"
|
name = "gpu-allocator"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
@@ -7161,7 +7526,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tungstenite",
|
"tungstenite 0.24.0",
|
||||||
"ws_stream_wasm",
|
"ws_stream_wasm",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7688,6 +8053,19 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-timeout"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||||
|
dependencies = [
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-tls"
|
name = "hyper-tls"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -8617,6 +8995,22 @@ dependencies = [
|
|||||||
"uuid-simd",
|
"uuid-simd",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "10.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"js-sys",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"signature",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kasuari"
|
name = "kasuari"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -10750,6 +11144,42 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry"
|
||||||
|
version = "0.32.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"js-sys",
|
||||||
|
"pin-project-lite",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-semantic-conventions"
|
||||||
|
version = "0.32.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c913ac17a6c451661ee255f4625d143e51647ae78ebd969b75e41c4442f4fe47"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry_sdk"
|
||||||
|
version = "0.32.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-util",
|
||||||
|
"opentelemetry",
|
||||||
|
"percent-encoding",
|
||||||
|
"portable-atomic",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -11513,6 +11943,16 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
|
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pretty_assertions"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
|
||||||
|
dependencies = [
|
||||||
|
"diff",
|
||||||
|
"yansi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -12735,6 +13175,89 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rig-bedrock"
|
||||||
|
version = "0.41.0"
|
||||||
|
source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
|
||||||
|
dependencies = [
|
||||||
|
"async-stream",
|
||||||
|
"aws-config",
|
||||||
|
"aws-sdk-bedrockruntime",
|
||||||
|
"aws-smithy-types",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"futures",
|
||||||
|
"rig-core",
|
||||||
|
"rig-derive",
|
||||||
|
"schemars 1.2.2",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-futures",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rig-core"
|
||||||
|
version = "0.41.0"
|
||||||
|
source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
|
||||||
|
dependencies = [
|
||||||
|
"as-any",
|
||||||
|
"async-stream",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"eventsource-stream",
|
||||||
|
"fastrand 2.5.0",
|
||||||
|
"futures",
|
||||||
|
"futures-timer",
|
||||||
|
"glob",
|
||||||
|
"http 1.5.0",
|
||||||
|
"indexmap 2.14.0",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"ordered-float 5.3.0",
|
||||||
|
"pin-project-lite",
|
||||||
|
"reqwest 0.13.4",
|
||||||
|
"rig-derive",
|
||||||
|
"schemars 1.2.2",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
|
"tracing",
|
||||||
|
"tracing-futures",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rig-derive"
|
||||||
|
version = "0.41.0"
|
||||||
|
source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
|
||||||
|
dependencies = [
|
||||||
|
"convert_case 0.11.0",
|
||||||
|
"proc-macro-crate 3.5.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rig-vertexai"
|
||||||
|
version = "0.41.0"
|
||||||
|
source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"google-cloud-aiplatform-v1",
|
||||||
|
"google-cloud-auth",
|
||||||
|
"rig-core",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -12745,7 +13268,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"libc",
|
"libc",
|
||||||
"untrusted",
|
"untrusted 0.9.0",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -12850,6 +13373,15 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roxmltree"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "921904a62e410e37e215c40381b7117f830d9d89ba60ab5236170541dd25646b"
|
||||||
|
dependencies = [
|
||||||
|
"xmlparser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "roxmltree"
|
name = "roxmltree"
|
||||||
version = "0.20.0"
|
version = "0.20.0"
|
||||||
@@ -13185,7 +13717,7 @@ dependencies = [
|
|||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted 0.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -13433,6 +13965,12 @@ version = "0.6.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "separator"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "seq-macro"
|
name = "seq-macro"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@@ -15220,6 +15758,22 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tungstenite 0.28.0",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.19"
|
version = "0.7.19"
|
||||||
@@ -15379,6 +15933,44 @@ version = "1.1.2+spec-1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tonic"
|
||||||
|
version = "0.14.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"http 1.5.0",
|
||||||
|
"http-body 1.1.0",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-timeout",
|
||||||
|
"hyper-util",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tokio-stream",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tonic-prost"
|
||||||
|
version = "0.14.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"prost",
|
||||||
|
"tonic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -15387,9 +15979,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"indexmap 2.14.0",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -15492,6 +16087,20 @@ dependencies = [
|
|||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-opentelemetry"
|
||||||
|
version = "0.33.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"opentelemetry",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-subscriber"
|
name = "tracing-subscriber"
|
||||||
version = "0.3.23"
|
version = "0.3.23"
|
||||||
@@ -15583,6 +16192,25 @@ dependencies = [
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http 1.5.0",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "twox-hash"
|
name = "twox-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -15870,6 +16498,12 @@ version = "0.2.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ publish = false
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Local workspace crates. This lets us reference them in other crates without specifying a path.
|
# Local workspace crates. This lets us reference them in other crates without specifying a path.
|
||||||
galaxy_acp = { path = "crates/acp" }
|
galaxy_acp = { path = "crates/acp" }
|
||||||
|
galaxy_agent_core = { path = "crates/galaxy_agent_core" }
|
||||||
|
galaxy_agent_rig = { path = "crates/galaxy_agent_rig" }
|
||||||
ai = { path = "crates/ai" }
|
ai = { path = "crates/ai" }
|
||||||
app-installation-detection = { path = "crates/app-installation-detection" }
|
app-installation-detection = { path = "crates/app-installation-detection" }
|
||||||
asset_cache = { path = "crates/asset_cache" }
|
asset_cache = { path = "crates/asset_cache" }
|
||||||
@@ -136,6 +138,9 @@ async-stream = "0.3.5"
|
|||||||
async-task = "4.2.0"
|
async-task = "4.2.0"
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
async-fs = "2.1.2"
|
async-fs = "2.1.2"
|
||||||
|
aws-sdk-bedrockruntime = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||||
|
aws-sdk-bedrock = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||||
|
aws-smithy-http-client = { version = "1", features = ["test-util"] }
|
||||||
backtrace = "0.3.76"
|
backtrace = "0.3.76"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
bincode = "1.3.3"
|
bincode = "1.3.3"
|
||||||
@@ -257,6 +262,9 @@ reqwest = { version = "0.13", features = [
|
|||||||
"stream",
|
"stream",
|
||||||
] }
|
] }
|
||||||
reqwest-eventsource = { package = "aha-reqwest-eventsource", version = "0.1" }
|
reqwest-eventsource = { package = "aha-reqwest-eventsource", version = "0.1" }
|
||||||
|
rig-core = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-core" }
|
||||||
|
rig-bedrock = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-bedrock" }
|
||||||
|
rig-vertexai = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-vertexai" }
|
||||||
resvg = "0.47.0"
|
resvg = "0.47.0"
|
||||||
rust-embed = { version = "8.7.0", features = ["include-exclude"] }
|
rust-embed = { version = "8.7.0", features = ["include-exclude"] }
|
||||||
rustc-hash = "2.1.1"
|
rustc-hash = "2.1.1"
|
||||||
|
|||||||
@@ -189,12 +189,12 @@ When adding/editing match statements, avoid using the wildcard _ when at all pos
|
|||||||
|
|
||||||
### Appearance Settings Notes
|
### Appearance Settings Notes
|
||||||
|
|
||||||
- Samsung-inspired built-in themes are available as `SamsungDark` and `SamsungLight`.
|
- Galaxy's built-in brand themes are available as `GalaxyDark` and `GalaxyDay`.
|
||||||
- UI font selection is persisted in `appearance.text.ui_font_name` and uses an empty string as the system-default sentinel.
|
- UI font selection is persisted in `appearance.text.ui_font_name` and uses an empty string as the system-default sentinel.
|
||||||
- The one-click Samsung brand preset is implemented in `app/src/settings_view/appearance_page.rs` and applies:
|
- The one-click Galaxy brand preset is implemented in `app/src/settings_view/appearance_page.rs` and applies:
|
||||||
- Samsung dark/light theme mapping
|
- Galaxy Dark/Day system theme mapping
|
||||||
- terminal + AI font defaults
|
- terminal + AI font defaults
|
||||||
- a best-available Samsung-style UI font fallback
|
- the bundled, SIL Open Font License-licensed Roboto UI font
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -1,112 +1,65 @@
|
|||||||
<a href="https://www.warp.dev">
|
# Galaxy
|
||||||
<img width="1024" alt="Warp Agentic Development Environment product preview" src="https://github.com/user-attachments/assets/9976b2da-2edd-4604-a36c-8fd53719c6d4" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.warp.dev"><img height="20" alt="Built with Warp" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/main/Github/Built-With-Warp-Export@2x.png" /></a>
|
|
||||||
|
|
||||||
<a href="https://oz.warp.dev"><img height="20" alt="Powered by Oz" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/main/Github/Powered-By-Oz-Export@2x.png" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
Galaxy is a local-first developer terminal with an agent that works alongside your shell. It is
|
||||||
<a href="https://www.warp.dev">Website</a>
|
designed to keep your conversations, settings, and personal workspace data on your machine while
|
||||||
·
|
letting you explicitly connect the model providers and local agents you choose.
|
||||||
<a href="https://www.warp.dev/code">Code</a>
|
|
||||||
·
|
|
||||||
<a href="https://www.warp.dev/agents">Agents</a>
|
|
||||||
·
|
|
||||||
<a href="https://www.warp.dev/terminal">Terminal</a>
|
|
||||||
·
|
|
||||||
<a href="https://www.warp.dev/drive">Drive</a>
|
|
||||||
·
|
|
||||||
<a href="https://docs.warp.dev">Docs</a>
|
|
||||||
·
|
|
||||||
<a href="https://www.warp.dev/blog/how-warp-works">How Warp Works</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
> [!NOTE]
|
## What Galaxy provides
|
||||||
> OpenAI is the founding sponsor of the new, open-source Warp repository, and the new agentic management workflows are powered by GPT models.
|
|
||||||
|
|
||||||
<h1></h1>
|
- A fast terminal, editor, and local workspace for development.
|
||||||
|
- Agent conversations with Galaxy-owned permissions for shell, file, and MCP actions.
|
||||||
|
- Provider connections for ChatGPT subscriptions, OpenAI-compatible APIs, Anthropic, Gemini,
|
||||||
|
Vertex AI, AWS Bedrock, and ACP agent runtimes.
|
||||||
|
- Local Galaxy Drive content, including rules, profiles, notebooks, workflows, and MCP settings.
|
||||||
|
- Explicit network boundaries: model traffic goes only to configured providers, and network tools
|
||||||
|
remain opt-in.
|
||||||
|
|
||||||
## About
|
## Building from source
|
||||||
|
|
||||||
[Warp](https://www.warp.dev) is an agentic development environment, born out of the terminal. Use Warp's built-in coding agent, or bring your own CLI agent (Claude Code, Codex, Gemini CLI, and others).
|
Galaxy is a Rust workspace. Platform setup and the common development tools are installed with:
|
||||||
|
|
||||||
## Installation
|
```bash
|
||||||
|
./script/bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
You can [download Warp](https://www.warp.dev/download) and [read our docs](https://docs.warp.dev/) for platform-specific instructions.
|
Then build or run the client with:
|
||||||
|
|
||||||
## Warp Contributions Overview Dashboard
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
Explore [build.warp.dev](https://build.warp.dev) to:
|
Before submitting changes, run the repository checks:
|
||||||
- Watch thousands of Oz agents triage issues, write specs, implement changes, and review PRs
|
|
||||||
- View top contributors and in-flight features
|
|
||||||
- Track your own issues with GitHub sign-in
|
|
||||||
- Click into active agent sessions in a web-compiled Warp terminal
|
|
||||||
|
|
||||||
## Oz for OSS
|
```bash
|
||||||
|
./script/format
|
||||||
|
cargo clippy --workspace --all-targets --all-features --tests -- -D warnings
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
Maintaining a popular open-source project? [Apply for Oz credits](https://tally.so/r/LZWxqG) to explore [Oz for OSS](https://github.com/warpdotdev/oz-for-oss).
|
See [AGENTS.md](AGENTS.md) for architecture notes, platform setup, coding conventions, and the
|
||||||
|
focused commands used by the project.
|
||||||
|
|
||||||
Oz for OSS is our partner program for bringing the same agentic open-source management workflows used in this repository to select partner repositories. We work directly with maintainers to implement workflows for issue triage, PR review, community management, and contributor coordination in a way that fits each project.
|
## Contributing
|
||||||
|
|
||||||
|
Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a change. Bug reports should include
|
||||||
|
reproduction steps, expected and actual behavior, platform details, and relevant logs or
|
||||||
|
screenshots that do not contain secrets. Security issues must be reported privately according to
|
||||||
|
[SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
UI changes should include manual verification, and visual changes should include before-and-after
|
||||||
|
screenshots when practical. Changes to local persistence, provider boundaries, or agent execution
|
||||||
|
should include focused automated coverage.
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
Warp's UI framework (the `warpui_core` and `warpui` crates) are licensed under the [MIT license](LICENSE-MIT).
|
The Galaxy application and most workspace crates are licensed under
|
||||||
|
[AGPL-3.0-only](LICENSE-AGPL). The GalaxyUI crates are licensed under the
|
||||||
|
[MIT License](LICENSE-MIT). Individual third-party components retain the licenses required by
|
||||||
|
their respective notices and source files.
|
||||||
|
|
||||||
The rest of the code in this repository is licensed under the [AGPL v3](LICENSE-AGPL).
|
## Project direction
|
||||||
|
|
||||||
## Open Source & Contributing
|
The migration plan in [plans/galaxy-local-first-rig.md](plans/galaxy-local-first-rig.md) records the
|
||||||
|
local-first architecture, provider boundary, and remaining work. Contributions should preserve
|
||||||
Warp's client codebase is open source and lives in this repository. We welcome community contributions and have designed a lightweight workflow to help new contributors get started. For the full contribution flow, read our [CONTRIBUTING.md](CONTRIBUTING.md) guide.
|
those boundaries: no inherited Warp service is required for a fresh install, and no provider or
|
||||||
|
agent may bypass Galaxy's permission and egress policy.
|
||||||
> [!TIP]
|
|
||||||
> **Chat with contributors and the Warp team** in the [`#oss-contributors`](https://warpcommunity.slack.com/archives/C0B0LM8N4DB) Slack channel — a good place for ad-hoc questions, design discussion, and pairing with maintainers. New here? [Join the Warp Slack community](https://go.warp.dev/join-preview) first, then jump into `#oss-contributors`.
|
|
||||||
|
|
||||||
### Issue to PR
|
|
||||||
|
|
||||||
Before filing, [search existing issues](https://github.com/warpdotdev/warp/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc) for your bug or feature request. If nothing exists, [file an issue](https://github.com/warpdotdev/warp/issues/new/choose) using our templates. Security vulnerabilities should be reported privately as described in [CONTRIBUTING.md](CONTRIBUTING.md#reporting-security-issues).
|
|
||||||
|
|
||||||
Once filed, a Warp maintainer reviews the issue and may apply a readiness label: [`ready-to-spec`](https://github.com/warpdotdev/warp/issues?q=is%3Aissue+is%3Aopen+label%3Aready-to-spec) signals the design is open for contributors to spec out, and [`ready-to-implement`](https://github.com/warpdotdev/warp/issues?q=is%3Aissue+is%3Aopen+label%3Aready-to-implement) signals the design is settled and code PRs are welcome. Anyone can pick up a labeled issue — mention **@oss-maintainers** on an issue if you'd like it considered for a readiness label.
|
|
||||||
|
|
||||||
### Building the Repo Locally
|
|
||||||
|
|
||||||
To build and run Warp from source:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./script/bootstrap # platform-specific setup
|
|
||||||
./script/run # build and run Warp
|
|
||||||
./script/presubmit # fmt, clippy, and tests
|
|
||||||
```
|
|
||||||
|
|
||||||
See [AGENTS.md](AGENTS.md) for the full engineering guide, including coding style, testing, and platform-specific notes.
|
|
||||||
|
|
||||||
## Joining the Team
|
|
||||||
|
|
||||||
Interested in joining the team? See our [open roles](https://www.warp.dev/careers).
|
|
||||||
|
|
||||||
## Support and Questions
|
|
||||||
|
|
||||||
1. See our [docs](https://docs.warp.dev/) for a comprehensive guide to Warp's features.
|
|
||||||
2. Join our [Slack Community](https://go.warp.dev/join-preview) to connect with other users and get help from the Warp team — contributors hang out in [`#oss-contributors`](https://warpcommunity.slack.com/archives/C0B0LM8N4DB).
|
|
||||||
3. Try our [Preview build](https://www.warp.dev/download-preview) to test the latest experimental features.
|
|
||||||
4. Mention **@oss-maintainers** on any issue to escalate to the team — for example, if you encounter problems with the automated agents.
|
|
||||||
|
|
||||||
## Code of Conduct
|
|
||||||
|
|
||||||
We ask everyone to be respectful and empathetic. Warp follows the [Code of Conduct](CODE_OF_CONDUCT.md). To report violations, email warp-coc at warp.dev.
|
|
||||||
|
|
||||||
## Open Source Dependencies
|
|
||||||
|
|
||||||
We'd like to call out a few of the [open source dependencies](https://docs.warp.dev/help/licenses) that have helped Warp to get off the ground:
|
|
||||||
|
|
||||||
- [Tokio](https://github.com/tokio-rs/tokio)
|
|
||||||
- [NuShell](https://github.com/nushell/nushell)
|
|
||||||
- [Fig Completion Specs](https://github.com/withfig/autocomplete)
|
|
||||||
- [Warp Server Framework](https://github.com/seanmonstar/warp)
|
|
||||||
- [Alacritty](https://github.com/alacritty/alacritty)
|
|
||||||
- [Hyper HTTP library](https://github.com/hyperium/hyper)
|
|
||||||
- [FontKit](https://github.com/servo/font-kit)
|
|
||||||
- [Core-foundation](https://github.com/servo/core-foundation-rs)
|
|
||||||
- [Smol](https://github.com/smol-rs/smol)
|
|
||||||
|
|||||||
+5
-2
@@ -5,7 +5,7 @@ description = "Galaxy - AI-powered terminal"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
autobins = false
|
autobins = false
|
||||||
name = "galaxy"
|
name = "galaxy"
|
||||||
version = "2.1.0"
|
version = "3.0.0"
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
@@ -234,6 +234,8 @@ warp_assets.workspace = true
|
|||||||
warp_channel_config.workspace = true
|
warp_channel_config.workspace = true
|
||||||
galaxy_completer.workspace = true
|
galaxy_completer.workspace = true
|
||||||
galaxy_core.workspace = true
|
galaxy_core.workspace = true
|
||||||
|
galaxy_agent_core.workspace = true
|
||||||
|
galaxy_agent_rig.workspace = true
|
||||||
galaxy_editor.workspace = true
|
galaxy_editor.workspace = true
|
||||||
galaxy_graphql.workspace = true
|
galaxy_graphql.workspace = true
|
||||||
galaxy_js = { workspace = true, optional = true }
|
galaxy_js = { workspace = true, optional = true }
|
||||||
@@ -326,7 +328,8 @@ tracing-subscriber.workspace = true
|
|||||||
# AWS SDK (loading credentials for BYO LLM)
|
# AWS SDK (loading credentials for BYO LLM)
|
||||||
aws-config = { version = "1.8.16", features = ["credentials-login"] }
|
aws-config = { version = "1.8.16", features = ["credentials-login"] }
|
||||||
aws-credential-types = "1"
|
aws-credential-types = "1"
|
||||||
aws-sdk-bedrockruntime = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
|
aws-sdk-bedrock.workspace = true
|
||||||
|
aws-sdk-bedrockruntime.workspace = true
|
||||||
aws-sdk-sts = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
|
aws-sdk-sts = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||||
aws-smithy-types = "1"
|
aws-smithy-types = "1"
|
||||||
aws-types = "1"
|
aws-types = "1"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
use galaxy_acp::{AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION};
|
use galaxy_acp::{
|
||||||
|
resolve_known_acp_agent, AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION,
|
||||||
|
OPENCODE_NPM_VERSION,
|
||||||
|
};
|
||||||
use sha2::{Digest as _, Sha256};
|
use sha2::{Digest as _, Sha256};
|
||||||
|
|
||||||
use crate::persistence::model::AcpConversationData;
|
use crate::persistence::model::AcpConversationData;
|
||||||
@@ -7,6 +10,14 @@ pub(crate) fn acp_model_id(agent_id: &str) -> String {
|
|||||||
format!("acp:{}", agent_id.trim().to_ascii_lowercase())
|
format!("acp:{}", agent_id.trim().to_ascii_lowercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn acp_provider_model_id(provider_id: &str, agent_id: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"acp:{}:{}",
|
||||||
|
provider_id.trim().to_ascii_lowercase(),
|
||||||
|
agent_id.trim().to_ascii_lowercase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn acp_selection_model_id(
|
pub(crate) fn acp_selection_model_id(
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
values: &std::collections::BTreeMap<String, serde_json::Value>,
|
values: &std::collections::BTreeMap<String, serde_json::Value>,
|
||||||
@@ -23,6 +34,21 @@ pub(crate) fn acp_selection_model_id(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn acp_provider_selection_identity(
|
||||||
|
provider_id: &str,
|
||||||
|
agent_id: &str,
|
||||||
|
values: &std::collections::BTreeMap<String, serde_json::Value>,
|
||||||
|
) -> String {
|
||||||
|
let mut identity = acp_provider_model_id(provider_id, agent_id);
|
||||||
|
for (key, value) in values {
|
||||||
|
identity.push(':');
|
||||||
|
identity.push_str(key);
|
||||||
|
identity.push('=');
|
||||||
|
identity.push_str(&canonical_json_value(value));
|
||||||
|
}
|
||||||
|
identity
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn acp_selection_identity(
|
pub(crate) fn acp_selection_identity(
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
values: &std::collections::BTreeMap<String, serde_json::Value>,
|
values: &std::collections::BTreeMap<String, serde_json::Value>,
|
||||||
@@ -53,7 +79,7 @@ fn canonical_json_value(value: &serde_json::Value) -> String {
|
|||||||
),
|
),
|
||||||
serde_json::Value::Object(values) => {
|
serde_json::Value::Object(values) => {
|
||||||
let mut entries = values.iter().collect::<Vec<_>>();
|
let mut entries = values.iter().collect::<Vec<_>>();
|
||||||
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
entries.sort_by_key(|(key, _)| *key);
|
||||||
format!(
|
format!(
|
||||||
"{{{}}}",
|
"{{{}}}",
|
||||||
entries
|
entries
|
||||||
@@ -215,9 +241,11 @@ pub(crate) fn resolve_acp_launch(
|
|||||||
match agent_id.trim().to_ascii_lowercase().as_str() {
|
match agent_id.trim().to_ascii_lowercase().as_str() {
|
||||||
"codex" => AcpAgentPreset::Codex.resolve_launch_config(),
|
"codex" => AcpAgentPreset::Codex.resolve_launch_config(),
|
||||||
"opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(),
|
"opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(),
|
||||||
unknown => Err(format!(
|
_ => resolve_known_acp_agent(agent_id).map_err(|error| {
|
||||||
"Unknown ACP agent preset {unknown:?}; choose \"codex\" or \"opencode\", or configure a custom ACP executable"
|
format!(
|
||||||
)),
|
"{error} Configure a custom ACP executable if this client uses a different command."
|
||||||
|
)
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use super::*;
|
|||||||
fn unknown_builtin_agent_ids_are_rejected() {
|
fn unknown_builtin_agent_ids_are_rejected() {
|
||||||
let error = resolve_acp_launch("mystery-agent", "", &[]).unwrap_err();
|
let error = resolve_acp_launch("mystery-agent", "", &[]).unwrap_err();
|
||||||
|
|
||||||
assert!(error.contains("Unknown ACP agent preset"));
|
assert!(error.contains("Unknown ACP agent"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -116,6 +116,7 @@ fn persisted_sessions_require_the_same_launch_identity() {
|
|||||||
let args = vec!["serve".to_owned()];
|
let args = vec!["serve".to_owned()];
|
||||||
let launch = resolve_acp_launch("custom", command, &args).unwrap();
|
let launch = resolve_acp_launch("custom", command, &args).unwrap();
|
||||||
let backend = AcpConversationData {
|
let backend = AcpConversationData {
|
||||||
|
provider_id: String::new(),
|
||||||
agent_id: "custom".to_owned(),
|
agent_id: "custom".to_owned(),
|
||||||
launch_fingerprint: acp_launch_fingerprint("custom", command, &args),
|
launch_fingerprint: acp_launch_fingerprint("custom", command, &args),
|
||||||
session_id: Some("session-123".to_owned()),
|
session_id: Some("session-123".to_owned()),
|
||||||
@@ -139,6 +140,7 @@ fn persisted_sessions_require_the_same_launch_identity() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn legacy_acp_sessions_fail_closed_without_a_launch_fingerprint() {
|
fn legacy_acp_sessions_fail_closed_without_a_launch_fingerprint() {
|
||||||
let backend = AcpConversationData {
|
let backend = AcpConversationData {
|
||||||
|
provider_id: String::new(),
|
||||||
agent_id: "codex".to_owned(),
|
agent_id: "codex".to_owned(),
|
||||||
launch_fingerprint: String::new(),
|
launch_fingerprint: String::new(),
|
||||||
session_id: Some("legacy-session".to_owned()),
|
session_id: Some("legacy-session".to_owned()),
|
||||||
|
|||||||
@@ -7,17 +7,17 @@
|
|||||||
mod launch;
|
mod launch;
|
||||||
mod permissions;
|
mod permissions;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod response_translator;
|
|
||||||
mod runtime_model;
|
mod runtime_model;
|
||||||
mod transport;
|
mod transport;
|
||||||
|
|
||||||
pub(crate) use launch::{
|
pub(crate) use launch::{
|
||||||
acp_launch_fingerprint, acp_model_id, acp_selection_identity, acp_selection_model_id,
|
acp_launch_fingerprint, acp_model_id, acp_provider_selection_identity, acp_selection_identity,
|
||||||
resolve_acp_launch, validate_acp_dispatch, validate_acp_launch_identity,
|
acp_selection_model_id, resolve_acp_launch, validate_acp_dispatch,
|
||||||
|
validate_acp_launch_identity,
|
||||||
};
|
};
|
||||||
pub(crate) use permissions::resolve_acp_permissions;
|
pub(crate) use permissions::resolve_acp_permissions;
|
||||||
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
|
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
|
||||||
pub(crate) use transport::{
|
pub(crate) use transport::{
|
||||||
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionHandleSlot,
|
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionMetadata,
|
||||||
AcpSessionMetadata, AcpSteeringRequest, GalaxyMcpTarget,
|
AcpTurnControlSlot, GalaxyMcpTarget,
|
||||||
};
|
};
|
||||||
|
|||||||
+39
-11
@@ -1,4 +1,5 @@
|
|||||||
use galaxy_acp::{ContentBlock, ImageContent, TextContent};
|
use base64::Engine as _;
|
||||||
|
use galaxy_agent_core::{ContentPart, MessageContent};
|
||||||
|
|
||||||
use crate::ai::agent::api::RequestParams;
|
use crate::ai::agent::api::RequestParams;
|
||||||
use crate::ai::agent::{AIAgentAttachment, AIAgentContext, AIAgentInput, MarkdownActionResult};
|
use crate::ai::agent::{AIAgentAttachment, AIAgentContext, AIAgentInput, MarkdownActionResult};
|
||||||
@@ -24,7 +25,7 @@ pub(super) struct GalaxyTerminalTools {
|
|||||||
pub(super) fn prompt_content(
|
pub(super) fn prompt_content(
|
||||||
params: &RequestParams,
|
params: &RequestParams,
|
||||||
terminal_tools: GalaxyTerminalTools,
|
terminal_tools: GalaxyTerminalTools,
|
||||||
) -> Result<Vec<ContentBlock>, String> {
|
) -> Result<MessageContent, String> {
|
||||||
let visible_query = params
|
let visible_query = params
|
||||||
.input
|
.input
|
||||||
.iter()
|
.iter()
|
||||||
@@ -41,12 +42,15 @@ pub(super) fn prompt_content(
|
|||||||
for item in context {
|
for item in context {
|
||||||
match item {
|
match item {
|
||||||
AIAgentContext::Image(image) => {
|
AIAgentContext::Image(image) => {
|
||||||
let mut file_name = image.file_name.clone();
|
let data = base64::engine::general_purpose::STANDARD
|
||||||
params.redact_text_for_model(&mut file_name);
|
.decode(&image.data)
|
||||||
images.push(ContentBlock::Image(
|
.map_err(|error| {
|
||||||
ImageContent::new(image.data.clone(), image.mime_type.clone())
|
format!("failed to decode ACP image attachment: {error}")
|
||||||
.uri(format!("attachment://{file_name}")),
|
})?;
|
||||||
));
|
images.push(ContentPart::Image {
|
||||||
|
data,
|
||||||
|
mime_type: image.mime_type.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
AIAgentContext::SelectedText(text) => {
|
AIAgentContext::SelectedText(text) => {
|
||||||
hidden_context.push(format!("Selected text:\n{text}"));
|
hidden_context.push(format!("Selected text:\n{text}"));
|
||||||
@@ -115,9 +119,14 @@ pub(super) fn prompt_content(
|
|||||||
}
|
}
|
||||||
params.redact_text_for_model(&mut text);
|
params.redact_text_for_model(&mut text);
|
||||||
|
|
||||||
let mut prompt = vec![ContentBlock::Text(TextContent::new(text))];
|
if images.is_empty() {
|
||||||
prompt.extend(images);
|
Ok(MessageContent::Text(text))
|
||||||
Ok(prompt)
|
} else {
|
||||||
|
let mut parts = Vec::with_capacity(images.len() + 1);
|
||||||
|
parts.push(ContentPart::Text(text));
|
||||||
|
parts.extend(images);
|
||||||
|
Ok(MessageContent::MultiPart(parts))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_hidden_input(
|
fn append_hidden_input(
|
||||||
@@ -144,6 +153,25 @@ fn append_hidden_input(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
AIAgentInput::UserQuery { .. } | AIAgentInput::CreateNewProject { .. } => {}
|
AIAgentInput::UserQuery { .. } | AIAgentInput::CreateNewProject { .. } => {}
|
||||||
|
AIAgentInput::CommandCompletionAssessment {
|
||||||
|
prompt,
|
||||||
|
completed_command,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
hidden_context.push(format!(
|
||||||
|
"A monitored command has completed.\n\
|
||||||
|
Command: {}\n\
|
||||||
|
Galaxy block_id: {}\n\
|
||||||
|
Final output:\n{}\n\n{}",
|
||||||
|
completed_command.command,
|
||||||
|
completed_command.block_id,
|
||||||
|
tail_chars(
|
||||||
|
&completed_command.grid_contents,
|
||||||
|
MAX_RUNNING_COMMAND_OUTPUT_CHARS
|
||||||
|
),
|
||||||
|
prompt,
|
||||||
|
));
|
||||||
|
}
|
||||||
AIAgentInput::AutoCodeDiffQuery { query, .. } => {
|
AIAgentInput::AutoCodeDiffQuery { query, .. } => {
|
||||||
hidden_context.push(format!(
|
hidden_context.push(format!(
|
||||||
"Galaxy system request: create a code diff.\n{query}"
|
"Galaxy system request: create a code diff.\n{query}"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use galaxy_acp::ContentBlock;
|
use galaxy_agent_core::{ContentPart, MessageContent};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
@@ -42,6 +42,19 @@ fn user_query(query: &str, context: Vec<AIAgentContext>) -> AIAgentInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prompt_text(prompt: &MessageContent) -> &str {
|
||||||
|
match prompt {
|
||||||
|
MessageContent::Text(text) => text,
|
||||||
|
MessageContent::MultiPart(parts) => match &parts[0] {
|
||||||
|
ContentPart::Text(text) => text,
|
||||||
|
_ => panic!("expected prompt text first"),
|
||||||
|
},
|
||||||
|
MessageContent::ToolUse { .. } | MessageContent::ToolResult { .. } => {
|
||||||
|
panic!("expected user prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keeps_images_as_native_acp_content() {
|
fn keeps_images_as_native_acp_content() {
|
||||||
let mut params = RequestParams::new_for_test();
|
let mut params = RequestParams::new_for_test();
|
||||||
@@ -56,17 +69,19 @@ fn keeps_images_as_native_acp_content() {
|
|||||||
)];
|
)];
|
||||||
|
|
||||||
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
||||||
assert_eq!(prompt.len(), 2);
|
let MessageContent::MultiPart(parts) = &prompt else {
|
||||||
|
panic!("expected multipart prompt");
|
||||||
|
};
|
||||||
|
assert_eq!(parts.len(), 2);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&prompt[0],
|
&parts[0],
|
||||||
ContentBlock::Text(text) if text.text == "What is in this image?"
|
ContentPart::Text(text) if text == "What is in this image?"
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&prompt[1],
|
&parts[1],
|
||||||
ContentBlock::Image(image)
|
ContentPart::Image { data, mime_type }
|
||||||
if image.data == "aW1hZ2U="
|
if data == b"image"
|
||||||
&& image.mime_type == "image/png"
|
&& mime_type == "image/png"
|
||||||
&& image.uri.as_deref() == Some("attachment://screen.png")
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,13 +95,11 @@ fn sends_rules_and_selected_text_without_changing_visible_query() {
|
|||||||
params.global_rules = vec![("Safety".to_owned(), "Run tests first.".to_owned())];
|
params.global_rules = vec![("Safety".to_owned(), "Run tests first.".to_owned())];
|
||||||
|
|
||||||
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
||||||
let ContentBlock::Text(text) = &prompt[0] else {
|
let text = prompt_text(&prompt);
|
||||||
panic!("expected text");
|
assert!(text.starts_with("Fix this"));
|
||||||
};
|
assert!(text.contains("hidden_from_transcript"));
|
||||||
assert!(text.text.starts_with("Fix this"));
|
assert!(text.contains("broken()"));
|
||||||
assert!(text.text.contains("hidden_from_transcript"));
|
assert!(text.contains("Run tests first."));
|
||||||
assert!(text.text.contains("broken()"));
|
|
||||||
assert!(text.text.contains("Run tests first."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -98,12 +111,50 @@ fn hidden_system_requests_still_reach_the_agent_without_a_user_bubble() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
||||||
let ContentBlock::Text(text) = &prompt[0] else {
|
let text = prompt_text(&prompt);
|
||||||
panic!("expected text");
|
assert!(text.starts_with("Handle the Galaxy system request"));
|
||||||
};
|
assert!(text.contains("Repair the failing unit test."));
|
||||||
assert!(text.text.starts_with("Handle the Galaxy system request"));
|
assert!(text.contains("hidden_from_transcript"));
|
||||||
assert!(text.text.contains("Repair the failing unit test."));
|
}
|
||||||
assert!(text.text.contains("hidden_from_transcript"));
|
|
||||||
|
#[test]
|
||||||
|
fn completed_command_assessment_uses_hidden_context_without_monitor_guidance() {
|
||||||
|
let block_id = BlockId::from("completed-session-42".to_owned());
|
||||||
|
let mut params = RequestParams::new_for_test();
|
||||||
|
params.input = vec![AIAgentInput::CommandCompletionAssessment {
|
||||||
|
prompt: "Report whether the command succeeded.".to_owned(),
|
||||||
|
context: Arc::from([AIAgentContext::SelectedText("root context".to_owned())]),
|
||||||
|
completed_command: RunningCommand {
|
||||||
|
command: "script/run-soak-test".to_owned(),
|
||||||
|
block_id: block_id.clone(),
|
||||||
|
grid_contents: "completed successfully".to_owned(),
|
||||||
|
cursor: String::new(),
|
||||||
|
requested_command_id: None,
|
||||||
|
is_alt_screen_active: false,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
|
||||||
|
let prompt = prompt_content(
|
||||||
|
¶ms,
|
||||||
|
GalaxyTerminalTools {
|
||||||
|
status: true,
|
||||||
|
interrupt: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("prompt");
|
||||||
|
let text = prompt_text(&prompt);
|
||||||
|
|
||||||
|
assert!(text.starts_with("Handle the Galaxy system request"));
|
||||||
|
assert!(text.contains("hidden_from_transcript"));
|
||||||
|
assert!(text.contains("A monitored command has completed."));
|
||||||
|
assert!(text.contains("script/run-soak-test"));
|
||||||
|
assert!(text.contains(block_id.as_str()));
|
||||||
|
assert!(text.contains("Final output:\ncompleted successfully"));
|
||||||
|
assert!(text.contains("Report whether the command succeeded."));
|
||||||
|
assert!(text.contains("Selected text:\nroot context"));
|
||||||
|
assert!(!text.contains("galaxy_terminal_status"));
|
||||||
|
assert!(!text.contains("galaxy_terminal_interrupt"));
|
||||||
|
assert!(!text.contains("running_for_ms"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -135,16 +186,14 @@ fn running_command_identity_and_output_are_sent_as_hidden_context() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("prompt");
|
.expect("prompt");
|
||||||
let ContentBlock::Text(text) = &prompt[0] else {
|
let text = prompt_text(&prompt);
|
||||||
panic!("expected text");
|
assert!(text.starts_with("Stop this after 75 seconds."));
|
||||||
};
|
assert!(text.contains(block_id.as_str()));
|
||||||
assert!(text.text.starts_with("Stop this after 75 seconds."));
|
assert!(text.contains("elapsed: 41s"));
|
||||||
assert!(text.text.contains(block_id.as_str()));
|
assert!(text.contains("galaxy_terminal_status"));
|
||||||
assert!(text.text.contains("elapsed: 41s"));
|
assert!(text.contains("running_for_ms"));
|
||||||
assert!(text.text.contains("galaxy_terminal_status"));
|
assert!(text.contains("galaxy_terminal_interrupt_at"));
|
||||||
assert!(text.text.contains("running_for_ms"));
|
assert!(text.contains("outside the model loop"));
|
||||||
assert!(text.text.contains("galaxy_terminal_interrupt_at"));
|
|
||||||
assert!(text.text.contains("outside the model loop"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -176,12 +225,10 @@ fn running_command_prompt_does_not_advertise_unavailable_mutations() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("prompt");
|
.expect("prompt");
|
||||||
let ContentBlock::Text(text) = &prompt[0] else {
|
let text = prompt_text(&prompt);
|
||||||
panic!("expected text");
|
assert!(text.contains("galaxy_terminal_status"));
|
||||||
};
|
assert!(text.contains("no Galaxy terminal mutation tool"));
|
||||||
assert!(text.text.contains("galaxy_terminal_status"));
|
assert!(!text.contains("galaxy_terminal_interrupt_at"));
|
||||||
assert!(text.text.contains("no Galaxy terminal mutation tool"));
|
|
||||||
assert!(!text.text.contains("galaxy_terminal_interrupt_at"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -224,20 +271,19 @@ fn redacts_request_text_before_creating_acp_content_blocks() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
||||||
let ContentBlock::Text(text) = &prompt[0] else {
|
let MessageContent::MultiPart(parts) = &prompt else {
|
||||||
panic!("expected text");
|
panic!("expected multipart prompt");
|
||||||
};
|
};
|
||||||
assert!(!text.text.contains(SECRET));
|
let text = prompt_text(&prompt);
|
||||||
assert!(text.text.contains("******************"));
|
assert!(!text.contains(SECRET));
|
||||||
assert!(text.text.contains("Selected text:"));
|
assert!(text.contains("******************"));
|
||||||
assert!(text.text.contains("Current output:"));
|
assert!(text.contains("Selected text:"));
|
||||||
assert!(text.text.contains("Attachment notes.txt:"));
|
assert!(text.contains("Current output:"));
|
||||||
assert!(text.text.contains("Galaxy rules:"));
|
assert!(text.contains("Attachment notes.txt:"));
|
||||||
|
assert!(text.contains("Galaxy rules:"));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&prompt[1],
|
&parts[1],
|
||||||
ContentBlock::Image(image)
|
ContentPart::Image { data, .. } if data == b"image"
|
||||||
if image.data == "aW1hZ2U="
|
|
||||||
&& !image.uri.as_deref().unwrap_or_default().contains(SECRET)
|
|
||||||
));
|
));
|
||||||
|
|
||||||
// Prompt redaction must not mutate the local transcript copy.
|
// Prompt redaction must not mutate the local transcript copy.
|
||||||
|
|||||||
@@ -1,335 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use galaxy_acp::{AcpEvent, ContentBlock, StopReason, ToolCallId, ToolCallStatus};
|
|
||||||
use uuid::Uuid;
|
|
||||||
use warp_multi_agent_api::response_event::stream_finished;
|
|
||||||
use warp_multi_agent_api::{self as api, ResponseEvent};
|
|
||||||
|
|
||||||
use crate::ai::bedrock::response_translator::{
|
|
||||||
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
|
|
||||||
build_user_query_message,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Stateful translation from ACP session updates to Galaxy's existing agent UI
|
|
||||||
/// response protocol.
|
|
||||||
pub(super) struct AcpResponseTranslator {
|
|
||||||
task_id: String,
|
|
||||||
request_id: String,
|
|
||||||
needs_create_task: bool,
|
|
||||||
user_query: Option<String>,
|
|
||||||
model_id: String,
|
|
||||||
initialized: bool,
|
|
||||||
message_id: Option<String>,
|
|
||||||
tool_titles: HashMap<ToolCallId, String>,
|
|
||||||
used_tokens: u64,
|
|
||||||
context_size: u64,
|
|
||||||
accept_next_user_content: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AcpResponseTranslator {
|
|
||||||
pub(super) fn new(
|
|
||||||
task_id: String,
|
|
||||||
needs_create_task: bool,
|
|
||||||
user_query: Option<String>,
|
|
||||||
model_id: String,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
task_id,
|
|
||||||
request_id: Uuid::new_v4().to_string(),
|
|
||||||
needs_create_task,
|
|
||||||
user_query,
|
|
||||||
model_id,
|
|
||||||
initialized: false,
|
|
||||||
message_id: None,
|
|
||||||
tool_titles: HashMap::new(),
|
|
||||||
used_tokens: 0,
|
|
||||||
context_size: 0,
|
|
||||||
accept_next_user_content: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn translate(&mut self, event: AcpEvent) -> Result<Vec<ResponseEvent>, String> {
|
|
||||||
let mut events = Vec::new();
|
|
||||||
match event {
|
|
||||||
AcpEvent::SessionStarted { .. } => self.initialize(&mut events),
|
|
||||||
AcpEvent::AgentText { text } => {
|
|
||||||
self.initialize(&mut events);
|
|
||||||
self.add_or_append(&text, &mut events);
|
|
||||||
}
|
|
||||||
// Reasoning is deliberately not copied into the plain assistant
|
|
||||||
// transcript. ACP agents can still expose plans and tool progress.
|
|
||||||
AcpEvent::AgentThought { .. } => {}
|
|
||||||
AcpEvent::AgentContent { content, thought } => {
|
|
||||||
if !thought {
|
|
||||||
self.initialize(&mut events);
|
|
||||||
let description = match content {
|
|
||||||
ContentBlock::Text(text) => text.text,
|
|
||||||
ContentBlock::Image(_) => "[Agent returned an image.]".to_owned(),
|
|
||||||
ContentBlock::Audio(_) => "[Agent returned audio.]".to_owned(),
|
|
||||||
ContentBlock::ResourceLink(resource) => {
|
|
||||||
format!("[Agent referenced {}.]", resource.name)
|
|
||||||
}
|
|
||||||
ContentBlock::Resource(_) => {
|
|
||||||
"[Agent returned embedded resource content.]".to_owned()
|
|
||||||
}
|
|
||||||
_ => "[Agent returned unsupported content.]".to_owned(),
|
|
||||||
};
|
|
||||||
self.add_or_append(&description, &mut events);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AcpEvent::UserContent { content } => {
|
|
||||||
// Some ACP adapters replay user-message chunks while loading a
|
|
||||||
// session or echo Galaxy's initial prompt, which also contains
|
|
||||||
// hidden context. Only content explicitly authorized by the
|
|
||||||
// live-steering path may enter the visible transcript.
|
|
||||||
if self.accept_next_user_content {
|
|
||||||
self.accept_next_user_content = false;
|
|
||||||
self.initialize(&mut events);
|
|
||||||
if let ContentBlock::Text(text) = content {
|
|
||||||
events.push(build_user_query_message(&self.task_id, &text.text));
|
|
||||||
// Assistant output after steering belongs in a new chat
|
|
||||||
// bubble, not the message that preceded the follow-up.
|
|
||||||
self.message_id = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AcpEvent::ToolCall {
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
status,
|
|
||||||
output,
|
|
||||||
} => {
|
|
||||||
self.initialize(&mut events);
|
|
||||||
self.tool_titles.insert(id, title.clone());
|
|
||||||
self.add_or_append(&tool_status_line(&title, status), &mut events);
|
|
||||||
if let Some(output) = output {
|
|
||||||
self.add_or_append(&tool_output_block(&output), &mut events);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AcpEvent::ToolCallUpdate {
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
status,
|
|
||||||
output,
|
|
||||||
} => {
|
|
||||||
self.initialize(&mut events);
|
|
||||||
let title = title
|
|
||||||
.or_else(|| self.tool_titles.get(&id).cloned())
|
|
||||||
.unwrap_or_else(|| "tool".to_owned());
|
|
||||||
self.tool_titles.insert(id, title.clone());
|
|
||||||
if let Some(status) = status {
|
|
||||||
self.add_or_append(&tool_status_line(&title, status), &mut events);
|
|
||||||
}
|
|
||||||
if let Some(output) = output {
|
|
||||||
self.add_or_append(&tool_output_block(&output), &mut events);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AcpEvent::Usage { used, size, .. } => {
|
|
||||||
self.used_tokens = used;
|
|
||||||
self.context_size = size;
|
|
||||||
}
|
|
||||||
AcpEvent::PermissionRequested { request } => {
|
|
||||||
self.initialize(&mut events);
|
|
||||||
self.add_or_append(
|
|
||||||
&format!(
|
|
||||||
"\n\n> Permission requested for: {}\n",
|
|
||||||
request.tool_call.fields.title.as_deref().unwrap_or("tool")
|
|
||||||
),
|
|
||||||
&mut events,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
AcpEvent::PermissionResolved { decision, .. } => {
|
|
||||||
self.initialize(&mut events);
|
|
||||||
self.add_or_append(
|
|
||||||
&format!("\n\n> Permission decision: {decision:?}\n"),
|
|
||||||
&mut events,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
AcpEvent::Finished { stop_reason } => {
|
|
||||||
self.initialize(&mut events);
|
|
||||||
if self.message_id.is_none() && stop_reason != StopReason::Cancelled {
|
|
||||||
self.add_or_append(
|
|
||||||
"> ACP agent completed without a text response.",
|
|
||||||
&mut events,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
events.push(self.finished(stop_reason));
|
|
||||||
}
|
|
||||||
AcpEvent::Error { message } => return Err(message),
|
|
||||||
// ACP events are forward-compatible. Unknown events do not belong
|
|
||||||
// in the user-visible transcript until Galaxy knows their meaning.
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
Ok(events)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn translate_steered_user_content(
|
|
||||||
&mut self,
|
|
||||||
content: ContentBlock,
|
|
||||||
) -> Result<Vec<ResponseEvent>, String> {
|
|
||||||
self.accept_next_user_content = true;
|
|
||||||
self.translate(AcpEvent::UserContent { content })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn steering_failed(&mut self, error: &str) -> Vec<ResponseEvent> {
|
|
||||||
let mut events = Vec::new();
|
|
||||||
self.initialize(&mut events);
|
|
||||||
self.message_id = None;
|
|
||||||
self.add_or_append(
|
|
||||||
&format!(
|
|
||||||
"Galaxy couldn't confirm that live steering message: {error}. \
|
|
||||||
The agent may not have received it; check the current terminal and file state \
|
|
||||||
before retrying."
|
|
||||||
),
|
|
||||||
&mut events,
|
|
||||||
);
|
|
||||||
// Any output still arriving from the original turn should not be
|
|
||||||
// appended to Galaxy's steering-failure notice.
|
|
||||||
self.message_id = None;
|
|
||||||
events
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn steering_started_new_turn(&mut self) -> Vec<ResponseEvent> {
|
|
||||||
let mut events = Vec::new();
|
|
||||||
self.initialize(&mut events);
|
|
||||||
self.message_id = None;
|
|
||||||
self.add_or_append(
|
|
||||||
"The ACP adapter started that steering message as a separate turn instead of \
|
|
||||||
injecting it into the active one. Galaxy terminated the adapter process immediately, \
|
|
||||||
but the turn may have begun acting; check the current terminal and file state before \
|
|
||||||
retrying.",
|
|
||||||
&mut events,
|
|
||||||
);
|
|
||||||
self.message_id = None;
|
|
||||||
events
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn startup_error(&mut self, error: &str) -> Vec<ResponseEvent> {
|
|
||||||
let mut events = Vec::new();
|
|
||||||
self.initialize(&mut events);
|
|
||||||
self.message_id = None;
|
|
||||||
self.add_or_append(
|
|
||||||
&format!("Galaxy couldn't start the ACP agent: {error}"),
|
|
||||||
&mut events,
|
|
||||||
);
|
|
||||||
events.push(self.finished(StopReason::Refusal));
|
|
||||||
events
|
|
||||||
}
|
|
||||||
|
|
||||||
fn initialize(&mut self, events: &mut Vec<ResponseEvent>) {
|
|
||||||
if self.initialized {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The ACP session ID is persisted separately. An empty conversation ID
|
|
||||||
// keeps this synthetic Init event out of Galaxy cloud token paths.
|
|
||||||
events.push(build_stream_init(&self.request_id, ""));
|
|
||||||
if self.needs_create_task {
|
|
||||||
events.push(build_create_task(&self.task_id));
|
|
||||||
}
|
|
||||||
if let Some(user_query) = &self.user_query {
|
|
||||||
events.push(build_user_query_message(&self.task_id, user_query));
|
|
||||||
}
|
|
||||||
self.initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_or_append(&mut self, text: &str, events: &mut Vec<ResponseEvent>) {
|
|
||||||
if text.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(message_id) = &self.message_id {
|
|
||||||
events.push(build_append_text(&self.task_id, message_id, text));
|
|
||||||
} else {
|
|
||||||
let message_id = Uuid::new_v4().to_string();
|
|
||||||
events.push(build_add_agent_output_message(
|
|
||||||
&self.task_id,
|
|
||||||
&message_id,
|
|
||||||
text,
|
|
||||||
));
|
|
||||||
self.message_id = Some(message_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finished(&self, stop_reason: StopReason) -> ResponseEvent {
|
|
||||||
let reason = match stop_reason {
|
|
||||||
StopReason::EndTurn | StopReason::Cancelled => {
|
|
||||||
stream_finished::Reason::Done(stream_finished::Done {})
|
|
||||||
}
|
|
||||||
StopReason::MaxTokens | StopReason::MaxTurnRequests => {
|
|
||||||
stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {})
|
|
||||||
}
|
|
||||||
StopReason::Refusal => stream_finished::Reason::Other(stream_finished::Other {}),
|
|
||||||
// ACP marks this enum non-exhaustive so newer agents can add stop reasons
|
|
||||||
// without breaking older clients.
|
|
||||||
_ => stream_finished::Reason::Other(stream_finished::Other {}),
|
|
||||||
};
|
|
||||||
let used_tokens = u32::try_from(self.used_tokens).unwrap_or(u32::MAX);
|
|
||||||
let context_usage = if self.context_size == 0 {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
(self.used_tokens as f32 / self.context_size as f32).clamp(0.0, 1.0)
|
|
||||||
};
|
|
||||||
#[allow(deprecated)]
|
|
||||||
let usage_metadata = stream_finished::ConversationUsageMetadata {
|
|
||||||
context_window_usage: context_usage,
|
|
||||||
summarized: false,
|
|
||||||
credits_spent: 0.0,
|
|
||||||
platform_credits_spent: 0.0,
|
|
||||||
total_input_tokens: used_tokens,
|
|
||||||
token_usage: Vec::new(),
|
|
||||||
tool_usage_metadata: None,
|
|
||||||
warp_token_usage: HashMap::new(),
|
|
||||||
byok_token_usage: HashMap::new(),
|
|
||||||
custom_endpoint_token_usage: HashMap::new(),
|
|
||||||
context_window_segments: Vec::new(),
|
|
||||||
};
|
|
||||||
ResponseEvent {
|
|
||||||
r#type: Some(api::response_event::Type::Finished(
|
|
||||||
api::response_event::StreamFinished {
|
|
||||||
reason: Some(reason),
|
|
||||||
token_usage: vec![stream_finished::TokenUsage {
|
|
||||||
model_id: self.model_id.clone(),
|
|
||||||
// ACP reports current context occupancy, not the input
|
|
||||||
// consumed by this individual request. Galaxy separately
|
|
||||||
// accumulates per-request token usage, so counting it
|
|
||||||
// here would grow the total again on every turn.
|
|
||||||
total_input: 0,
|
|
||||||
output: 0,
|
|
||||||
input_cache_read: 0,
|
|
||||||
input_cache_write: 0,
|
|
||||||
cost_in_cents: 0.0,
|
|
||||||
}],
|
|
||||||
should_refresh_model_config: false,
|
|
||||||
request_cost: None,
|
|
||||||
conversation_usage_metadata: Some(usage_metadata),
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tool_status_line(title: &str, status: ToolCallStatus) -> String {
|
|
||||||
let status = match status {
|
|
||||||
ToolCallStatus::Pending => "waiting",
|
|
||||||
ToolCallStatus::InProgress => "running",
|
|
||||||
ToolCallStatus::Completed => "completed",
|
|
||||||
ToolCallStatus::Failed => "failed",
|
|
||||||
// ACP marks this enum non-exhaustive. Preserve a useful transcript if a
|
|
||||||
// newer agent reports a status this client does not recognize yet.
|
|
||||||
_ => "updated",
|
|
||||||
};
|
|
||||||
format!("\n\n> **{title}** — {status}\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tool_output_block(output: &str) -> String {
|
|
||||||
let mut block = String::from("\n");
|
|
||||||
for line in output.lines() {
|
|
||||||
block.push_str(" ");
|
|
||||||
block.push_str(line);
|
|
||||||
block.push('\n');
|
|
||||||
}
|
|
||||||
block
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "response_translator_tests.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
use galaxy_acp::{
|
|
||||||
AcpEvent, AgentCapabilities, ContentBlock, SessionId, StopReason, TextContent, ToolCallId,
|
|
||||||
ToolCallStatus,
|
|
||||||
};
|
|
||||||
use warp_multi_agent_api::{client_action, message, response_event};
|
|
||||||
|
|
||||||
use super::AcpResponseTranslator;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn initializes_the_existing_chat_exchange_and_persists_user_text() {
|
|
||||||
let mut translator = AcpResponseTranslator::new(
|
|
||||||
"task".to_owned(),
|
|
||||||
true,
|
|
||||||
Some("hello".to_owned()),
|
|
||||||
"acp:codex".to_owned(),
|
|
||||||
);
|
|
||||||
let events = translator
|
|
||||||
.translate(AcpEvent::SessionStarted {
|
|
||||||
session_id: SessionId::from("session"),
|
|
||||||
agent_info: None,
|
|
||||||
capabilities: AgentCapabilities::default(),
|
|
||||||
can_load: true,
|
|
||||||
can_steer: true,
|
|
||||||
})
|
|
||||||
.expect("translate");
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
events[0].r#type,
|
|
||||||
Some(response_event::Type::Init(_))
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
events[1].r#type,
|
|
||||||
Some(response_event::Type::ClientActions(_))
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
events[2].r#type,
|
|
||||||
Some(response_event::Type::ClientActions(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn streams_agent_text_as_add_then_append() {
|
|
||||||
let mut translator =
|
|
||||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
|
||||||
let first = translator
|
|
||||||
.translate(AcpEvent::AgentText {
|
|
||||||
text: "one".to_owned(),
|
|
||||||
})
|
|
||||||
.expect("first");
|
|
||||||
let second = translator
|
|
||||||
.translate(AcpEvent::AgentText {
|
|
||||||
text: " two".to_owned(),
|
|
||||||
})
|
|
||||||
.expect("second");
|
|
||||||
|
|
||||||
let Some(response_event::Type::ClientActions(first_actions)) = &first[1].r#type else {
|
|
||||||
panic!("expected first client action");
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
first_actions.actions[0].action,
|
|
||||||
Some(client_action::Action::AddMessagesToTask(_))
|
|
||||||
));
|
|
||||||
let Some(response_event::Type::ClientActions(second_actions)) = &second[0].r#type else {
|
|
||||||
panic!("expected append client action");
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
second_actions.actions[0].action,
|
|
||||||
Some(client_action::Action::AppendToMessageContent(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renders_acp_tool_progress_as_text_not_an_executable_galaxy_action() {
|
|
||||||
let mut translator =
|
|
||||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
|
||||||
let events = translator
|
|
||||||
.translate(AcpEvent::ToolCall {
|
|
||||||
id: ToolCallId::from("tool-1"),
|
|
||||||
title: "Read file".to_owned(),
|
|
||||||
status: ToolCallStatus::InProgress,
|
|
||||||
output: None,
|
|
||||||
})
|
|
||||||
.expect("tool");
|
|
||||||
let Some(response_event::Type::ClientActions(actions)) = &events[1].r#type else {
|
|
||||||
panic!("expected client action");
|
|
||||||
};
|
|
||||||
let Some(client_action::Action::AddMessagesToTask(add)) = &actions.actions[0].action else {
|
|
||||||
panic!("expected display-only message");
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
add.messages[0].message,
|
|
||||||
Some(message::Message::AgentOutput(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn maps_usage_and_successful_completion() {
|
|
||||||
let mut translator =
|
|
||||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
|
||||||
translator
|
|
||||||
.translate(AcpEvent::Usage {
|
|
||||||
used: 25,
|
|
||||||
size: 100,
|
|
||||||
cost: None,
|
|
||||||
})
|
|
||||||
.expect("usage");
|
|
||||||
let events = translator
|
|
||||||
.translate(AcpEvent::Finished {
|
|
||||||
stop_reason: StopReason::EndTurn,
|
|
||||||
})
|
|
||||||
.expect("finished");
|
|
||||||
let Some(finished) = events.iter().find_map(|event| {
|
|
||||||
let Some(response_event::Type::Finished(finished)) = &event.r#type else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
Some(finished)
|
|
||||||
}) else {
|
|
||||||
panic!("expected finished");
|
|
||||||
};
|
|
||||||
assert_eq!(finished.token_usage[0].total_input, 0);
|
|
||||||
assert_eq!(
|
|
||||||
finished
|
|
||||||
.conversation_usage_metadata
|
|
||||||
.as_ref()
|
|
||||||
.expect("metadata")
|
|
||||||
.context_window_usage,
|
|
||||||
0.25
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renders_bounded_tool_output_in_the_agent_transcript() {
|
|
||||||
let mut translator =
|
|
||||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
|
||||||
let events = translator
|
|
||||||
.translate(AcpEvent::ToolCall {
|
|
||||||
id: ToolCallId::from("tool-1"),
|
|
||||||
title: "Run tests".to_owned(),
|
|
||||||
status: ToolCallStatus::Completed,
|
|
||||||
output: Some("test one ... ok\ntest two ... ok".to_owned()),
|
|
||||||
})
|
|
||||||
.expect("tool");
|
|
||||||
|
|
||||||
let Some(response_event::Type::ClientActions(status_actions)) = &events[1].r#type else {
|
|
||||||
panic!("expected status action");
|
|
||||||
};
|
|
||||||
let Some(client_action::Action::AddMessagesToTask(add_status)) =
|
|
||||||
&status_actions.actions[0].action
|
|
||||||
else {
|
|
||||||
panic!("expected status message");
|
|
||||||
};
|
|
||||||
let Some(message::Message::AgentOutput(status)) = &add_status.messages[0].message else {
|
|
||||||
panic!("expected agent output");
|
|
||||||
};
|
|
||||||
assert!(status.text.contains("Run tests"));
|
|
||||||
|
|
||||||
let Some(response_event::Type::ClientActions(output_actions)) = &events[2].r#type else {
|
|
||||||
panic!("expected output action");
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
output_actions.actions[0].action,
|
|
||||||
Some(client_action::Action::AppendToMessageContent(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn successful_turn_without_agent_output_is_still_visible() {
|
|
||||||
let mut translator =
|
|
||||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
|
||||||
let events = translator
|
|
||||||
.translate(AcpEvent::Finished {
|
|
||||||
stop_reason: StopReason::EndTurn,
|
|
||||||
})
|
|
||||||
.expect("finished");
|
|
||||||
|
|
||||||
assert!(events.iter().any(|event| {
|
|
||||||
let Some(response_event::Type::ClientActions(actions)) = &event.r#type else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(client_action::Action::AddMessagesToTask(add)) = &actions.actions[0].action else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(message::Message::AgentOutput(output)) = &add.messages[0].message else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
output.text.contains("completed without a text response")
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn suppresses_unsolicited_user_content_so_initial_hidden_context_cannot_leak() {
|
|
||||||
let mut translator =
|
|
||||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
|
||||||
|
|
||||||
let events = translator
|
|
||||||
.translate(AcpEvent::UserContent {
|
|
||||||
content: ContentBlock::Text(TextContent::new(
|
|
||||||
"hidden initial prompt and system context",
|
|
||||||
)),
|
|
||||||
})
|
|
||||||
.expect("translate");
|
|
||||||
|
|
||||||
assert!(events.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn live_steering_adds_a_user_bubble_and_starts_a_new_assistant_bubble() {
|
|
||||||
let mut translator =
|
|
||||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
|
||||||
translator
|
|
||||||
.translate(AcpEvent::AgentText {
|
|
||||||
text: "original response".to_owned(),
|
|
||||||
})
|
|
||||||
.expect("initial output");
|
|
||||||
|
|
||||||
let steered = translator
|
|
||||||
.translate_steered_user_content(ContentBlock::Text(TextContent::new("stop at 75s")))
|
|
||||||
.expect("steering");
|
|
||||||
let Some(response_event::Type::ClientActions(user_actions)) = &steered[0].r#type else {
|
|
||||||
panic!("expected user client action");
|
|
||||||
};
|
|
||||||
let Some(client_action::Action::AddMessagesToTask(add_user)) = &user_actions.actions[0].action
|
|
||||||
else {
|
|
||||||
panic!("expected user message");
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
add_user.messages[0].message,
|
|
||||||
Some(message::Message::UserQuery(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
let resumed = translator
|
|
||||||
.translate(AcpEvent::AgentText {
|
|
||||||
text: "steered response".to_owned(),
|
|
||||||
})
|
|
||||||
.expect("resumed output");
|
|
||||||
let Some(response_event::Type::ClientActions(agent_actions)) = &resumed[0].r#type else {
|
|
||||||
panic!("expected agent client action");
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
agent_actions.actions[0].action,
|
|
||||||
Some(client_action::Action::AddMessagesToTask(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn steering_failure_surfaces_an_indeterminate_delivery_warning() {
|
|
||||||
let mut translator =
|
|
||||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
|
||||||
translator
|
|
||||||
.translate(AcpEvent::SessionStarted {
|
|
||||||
session_id: SessionId::from("session"),
|
|
||||||
agent_info: None,
|
|
||||||
capabilities: AgentCapabilities::default(),
|
|
||||||
can_load: true,
|
|
||||||
can_steer: true,
|
|
||||||
})
|
|
||||||
.expect("initialize");
|
|
||||||
|
|
||||||
let events = translator.steering_failed("turn is no longer active");
|
|
||||||
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
let Some(response_event::Type::ClientActions(error_actions)) = &events[0].r#type else {
|
|
||||||
panic!("expected visible error action");
|
|
||||||
};
|
|
||||||
let Some(client_action::Action::AddMessagesToTask(add_error)) =
|
|
||||||
&error_actions.actions[0].action
|
|
||||||
else {
|
|
||||||
panic!("expected visible error message");
|
|
||||||
};
|
|
||||||
let Some(message::Message::AgentOutput(output)) = &add_error.messages[0].message else {
|
|
||||||
panic!("expected agent output");
|
|
||||||
};
|
|
||||||
assert!(output.text.contains("couldn't confirm"));
|
|
||||||
assert!(output.text.contains("before retrying"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn implicit_steering_turn_warning_does_not_recommend_a_blind_retry() {
|
|
||||||
let mut translator =
|
|
||||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
|
||||||
|
|
||||||
let events = translator.steering_started_new_turn();
|
|
||||||
|
|
||||||
let text =
|
|
||||||
events
|
|
||||||
.iter()
|
|
||||||
.filter_map(|event| match &event.r#type {
|
|
||||||
Some(response_event::Type::ClientActions(actions)) => actions
|
|
||||||
.actions
|
|
||||||
.iter()
|
|
||||||
.find_map(|action| match &action.action {
|
|
||||||
Some(client_action::Action::AddMessagesToTask(add)) => add
|
|
||||||
.messages
|
|
||||||
.iter()
|
|
||||||
.find_map(|message| match &message.message {
|
|
||||||
Some(message::Message::AgentOutput(output)) => {
|
|
||||||
Some(output.text.as_str())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect::<String>();
|
|
||||||
assert!(text.contains("started"));
|
|
||||||
assert!(text.contains("terminated"));
|
|
||||||
assert!(text.contains("immediately"));
|
|
||||||
assert!(text.contains("may have begun acting"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn startup_error_keeps_the_user_request_and_finishes_visibly() {
|
|
||||||
let mut translator = AcpResponseTranslator::new(
|
|
||||||
"task".to_owned(),
|
|
||||||
false,
|
|
||||||
Some("help me".to_owned()),
|
|
||||||
"acp:codex".to_owned(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let events = translator.startup_error("adapter missing");
|
|
||||||
|
|
||||||
assert_eq!(events.len(), 4);
|
|
||||||
assert!(matches!(
|
|
||||||
events[0].r#type,
|
|
||||||
Some(response_event::Type::Init(_))
|
|
||||||
));
|
|
||||||
let Some(response_event::Type::ClientActions(user_actions)) = &events[1].r#type else {
|
|
||||||
panic!("expected visible user request");
|
|
||||||
};
|
|
||||||
let Some(client_action::Action::AddMessagesToTask(user_messages)) =
|
|
||||||
&user_actions.actions[0].action
|
|
||||||
else {
|
|
||||||
panic!("expected user message");
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
user_messages.messages[0].message,
|
|
||||||
Some(message::Message::UserQuery(_))
|
|
||||||
));
|
|
||||||
let Some(response_event::Type::ClientActions(error_actions)) = &events[2].r#type else {
|
|
||||||
panic!("expected visible startup error");
|
|
||||||
};
|
|
||||||
let Some(client_action::Action::AddMessagesToTask(error_messages)) =
|
|
||||||
&error_actions.actions[0].action
|
|
||||||
else {
|
|
||||||
panic!("expected error message");
|
|
||||||
};
|
|
||||||
let Some(message::Message::AgentOutput(output)) = &error_messages.messages[0].message else {
|
|
||||||
panic!("expected agent output");
|
|
||||||
};
|
|
||||||
assert!(output.text.contains("adapter missing"));
|
|
||||||
assert!(matches!(
|
|
||||||
events[3].r#type,
|
|
||||||
Some(response_event::Type::Finished(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
@@ -86,17 +86,12 @@ impl AcpRuntimeModel {
|
|||||||
Ok(manager)
|
Ok(manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn discovery_config(settings: &AISettings) -> Result<AcpManagerConfig, String> {
|
pub(crate) fn discovery_config_for_values(
|
||||||
let agent_id = if settings.acp_agent_id.value().trim().is_empty() {
|
agent_id: &str,
|
||||||
"codex"
|
command: &str,
|
||||||
} else {
|
args: &[String],
|
||||||
settings.acp_agent_id.value().trim()
|
) -> Result<AcpManagerConfig, String> {
|
||||||
};
|
let launch = crate::ai::acp::resolve_acp_launch(agent_id, command, args)?;
|
||||||
let launch = crate::ai::acp::resolve_acp_launch(
|
|
||||||
agent_id,
|
|
||||||
settings.acp_agent_command.value(),
|
|
||||||
settings.acp_agent_args.value(),
|
|
||||||
)?;
|
|
||||||
Ok(AcpManagerConfig::new(launch))
|
Ok(AcpManagerConfig::new(launch))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,10 +159,8 @@ impl AcpRuntimeModel {
|
|||||||
) -> BTreeMap<String, serde_json::Value> {
|
) -> BTreeMap<String, serde_json::Value> {
|
||||||
options
|
options
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|option| {
|
.filter(|option| !option.current_value.is_null())
|
||||||
(!option.current_value.is_null())
|
.map(|option| (option.id.clone(), option.current_value.clone()))
|
||||||
.then(|| (option.id.clone(), option.current_value.clone()))
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+94
-175
@@ -1,38 +1,27 @@
|
|||||||
use std::collections::VecDeque;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use futures::channel::oneshot;
|
use futures::channel::oneshot;
|
||||||
use futures::future::{BoxFuture, Fuse, FusedFuture as _};
|
|
||||||
use futures::stream::FusedStream as _;
|
|
||||||
use futures::{FutureExt as _, StreamExt as _};
|
use futures::{FutureExt as _, StreamExt as _};
|
||||||
use galaxy_acp::{
|
use galaxy_acp::{
|
||||||
AcpEvent, AcpPermissionPolicy, AcpRuntimeError, AcpSessionHandle, AcpSessionManager,
|
AcpAgentRuntime, AcpAgentRuntimeConfig, AcpPermissionPolicy, AcpRuntimeState,
|
||||||
AcpSteeringOutcome, AcpTurnRequest, ContentBlock, McpServer, McpServerStdio,
|
AcpRuntimeStateHandle, AcpSessionManager, McpServer, McpServerStdio, SessionConfigOptionValue,
|
||||||
SessionConfigOptionValue, SessionId, TextContent,
|
SessionId,
|
||||||
|
};
|
||||||
|
use galaxy_agent_core::{
|
||||||
|
turn_control, AgentRuntime as _, RuntimeCapabilities, TurnCommand, TurnCommandSender,
|
||||||
|
TurnRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::launch::acp_selection_identity;
|
use super::launch::{acp_provider_selection_identity, acp_selection_identity};
|
||||||
use super::prompt::{prompt_content, GalaxyTerminalTools};
|
use super::prompt::{prompt_content, GalaxyTerminalTools};
|
||||||
use super::response_translator::AcpResponseTranslator;
|
|
||||||
use crate::ai::agent::api::{self, RequestParams};
|
use crate::ai::agent::api::{self, RequestParams};
|
||||||
use crate::ai::agent::EntrypointType;
|
use crate::ai::agent::EntrypointType;
|
||||||
|
use crate::ai::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator};
|
||||||
use crate::persistence::model::AcpConversationData;
|
use crate::persistence::model::AcpConversationData;
|
||||||
use crate::server::server_api::AIApiError;
|
use crate::server::server_api::AIApiError;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
pub(crate) type AcpSessionMetadata = AcpRuntimeState;
|
||||||
pub(crate) struct AcpSessionMetadata {
|
|
||||||
pub(crate) session_id: Option<String>,
|
|
||||||
pub(crate) can_load: bool,
|
|
||||||
pub(crate) can_steer: bool,
|
|
||||||
pub(crate) config_options: Vec<galaxy_acp::SessionConfigOption>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub(crate) struct AcpSteeringRequest {
|
|
||||||
display_text: String,
|
|
||||||
model_text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub(crate) struct GalaxyMcpTarget {
|
pub(crate) struct GalaxyMcpTarget {
|
||||||
@@ -41,46 +30,25 @@ pub(crate) struct GalaxyMcpTarget {
|
|||||||
pub(crate) pane_id: String,
|
pub(crate) pane_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AcpSteeringRequest {
|
pub(crate) type AcpTurnControlSlot = Arc<Mutex<Option<TurnCommandSender>>>;
|
||||||
pub(crate) fn text(display_text: String, model_text: String) -> Self {
|
|
||||||
Self {
|
struct AcpTurnControlGuard {
|
||||||
display_text,
|
slot: AcpTurnControlSlot,
|
||||||
model_text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SteeringResult = Result<AcpSteeringOutcome, AcpRuntimeError>;
|
impl AcpTurnControlGuard {
|
||||||
type PendingSteering = Fuse<BoxFuture<'static, SteeringResult>>;
|
fn new(slot: AcpTurnControlSlot, control: TurnCommandSender) -> Self {
|
||||||
|
if let Ok(mut active_control) = slot.lock() {
|
||||||
fn pending_steering(session: AcpSessionHandle, steering: AcpSteeringRequest) -> PendingSteering {
|
*active_control = Some(control);
|
||||||
async move {
|
|
||||||
let content = ContentBlock::Text(TextContent::new(steering.model_text));
|
|
||||||
session.steer(vec![content]).await
|
|
||||||
}
|
|
||||||
.boxed()
|
|
||||||
.fuse()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) type AcpSessionHandleSlot = Arc<Mutex<Option<AcpSessionHandle>>>;
|
|
||||||
|
|
||||||
struct AcpSessionHandleGuard {
|
|
||||||
slot: AcpSessionHandleSlot,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AcpSessionHandleGuard {
|
|
||||||
fn new(slot: AcpSessionHandleSlot, session: AcpSessionHandle) -> Self {
|
|
||||||
if let Ok(mut active_session) = slot.lock() {
|
|
||||||
*active_session = Some(session);
|
|
||||||
}
|
}
|
||||||
Self { slot }
|
Self { slot }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for AcpSessionHandleGuard {
|
impl Drop for AcpTurnControlGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Ok(mut active_session) = self.slot.lock() {
|
if let Ok(mut active_control) = self.slot.lock() {
|
||||||
*active_session = None;
|
*active_control = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,9 +63,8 @@ pub(crate) async fn acp_output_stream(
|
|||||||
galaxy_terminal_interrupt_available: bool,
|
galaxy_terminal_interrupt_available: bool,
|
||||||
permission_policy: AcpPermissionPolicy,
|
permission_policy: AcpPermissionPolicy,
|
||||||
auto_approve_permissions: bool,
|
auto_approve_permissions: bool,
|
||||||
session_metadata: Arc<Mutex<AcpSessionMetadata>>,
|
session_metadata: AcpRuntimeStateHandle,
|
||||||
session_handle: AcpSessionHandleSlot,
|
turn_control_slot: AcpTurnControlSlot,
|
||||||
steering_rx: async_channel::Receiver<AcpSteeringRequest>,
|
|
||||||
cancellation_rx: oneshot::Receiver<()>,
|
cancellation_rx: oneshot::Receiver<()>,
|
||||||
) -> api::ResponseStream {
|
) -> api::ResponseStream {
|
||||||
let mut translator = response_translator(¶ms, &backend);
|
let mut translator = response_translator(¶ms, &backend);
|
||||||
@@ -121,136 +88,69 @@ pub(crate) async fn acp_output_stream(
|
|||||||
if let Some(server) = galaxy_mcp_server {
|
if let Some(server) = galaxy_mcp_server {
|
||||||
mcp_servers.push(server);
|
mcp_servers.push(server);
|
||||||
}
|
}
|
||||||
let request = AcpTurnRequest {
|
let runtime_id = if backend.provider_id.is_empty() {
|
||||||
config_values: backend
|
acp_selection_identity(&backend.agent_id, &backend.config_values)
|
||||||
.config_values
|
} else {
|
||||||
.into_iter()
|
acp_provider_selection_identity(
|
||||||
.filter_map(|(key, value)| {
|
&backend.provider_id,
|
||||||
serde_json::from_value::<SessionConfigOptionValue>(value)
|
&backend.agent_id,
|
||||||
.ok()
|
&backend.config_values,
|
||||||
.map(|value| (key, value))
|
)
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
conversation_key: conversation_id,
|
|
||||||
session_id: backend.session_id.map(SessionId::from),
|
|
||||||
cwd,
|
|
||||||
additional_directories: Vec::new(),
|
|
||||||
prompt,
|
|
||||||
mcp_servers,
|
|
||||||
auto_approve_permissions,
|
|
||||||
permission_policy,
|
|
||||||
prompt_capabilities: Default::default(),
|
|
||||||
};
|
};
|
||||||
let (session, events) = match manager.run_turn(request) {
|
let mut runtime_config =
|
||||||
Ok(turn) => turn,
|
AcpAgentRuntimeConfig::new(runtime_id.clone(), backend.agent_id.clone(), cwd);
|
||||||
|
runtime_config.config_values = backend
|
||||||
|
.config_values
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(key, value)| {
|
||||||
|
serde_json::from_value::<SessionConfigOptionValue>(value.clone())
|
||||||
|
.ok()
|
||||||
|
.map(|value| (key.clone(), value))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
runtime_config.session_id = backend.session_id.clone().map(SessionId::from);
|
||||||
|
runtime_config.mcp_servers = mcp_servers;
|
||||||
|
runtime_config.auto_approve_permissions = auto_approve_permissions;
|
||||||
|
runtime_config.permission_policy = permission_policy;
|
||||||
|
let runtime = AcpAgentRuntime::new(manager, runtime_config).with_state(session_metadata);
|
||||||
|
let mut request = TurnRequest::new(runtime_id, Vec::new()).with_prompt(prompt);
|
||||||
|
request.conversation_id = Some(conversation_id);
|
||||||
|
let (control_sender, control) = turn_control();
|
||||||
|
let events = match runtime.start_turn(request, control).await {
|
||||||
|
Ok(events) => events,
|
||||||
Err(error) => return translated_startup_error_stream(translator, &error.to_string()),
|
Err(error) => return translated_startup_error_stream(translator, &error.to_string()),
|
||||||
};
|
};
|
||||||
let session_handle_guard = AcpSessionHandleGuard::new(session_handle, session.clone());
|
let turn_control_guard = AcpTurnControlGuard::new(turn_control_slot, control_sender.clone());
|
||||||
|
|
||||||
let stream = async_stream::stream! {
|
let stream = async_stream::stream! {
|
||||||
let _session_handle_guard = session_handle_guard;
|
let _turn_control_guard = turn_control_guard;
|
||||||
let mut cancellation_rx = cancellation_rx.fuse();
|
let mut cancellation_rx = cancellation_rx.fuse();
|
||||||
let mut events = Box::pin(events.fuse());
|
let mut events = events.fuse();
|
||||||
let mut steering_rx = Box::pin(steering_rx.fuse());
|
|
||||||
let mut steering_queue = VecDeque::new();
|
|
||||||
let mut steering_result: PendingSteering = Fuse::terminated();
|
|
||||||
loop {
|
loop {
|
||||||
futures::select_biased! {
|
futures::select_biased! {
|
||||||
_ = cancellation_rx => {
|
_ = cancellation_rx => {
|
||||||
if let Err(error) = session.cancel().await {
|
if let Err(error) = control_sender.try_send(TurnCommand::Cancel) {
|
||||||
log::warn!("Failed to cancel ACP turn cleanly: {error}");
|
log::warn!("Failed to queue ACP cancellation: {error}");
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
steering = steering_rx.next() => {
|
|
||||||
let Some(steering) = steering else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let content = ContentBlock::Text(TextContent::new(
|
|
||||||
steering.display_text.clone(),
|
|
||||||
));
|
|
||||||
match translator.translate_steered_user_content(content) {
|
|
||||||
Ok(response_events) => {
|
|
||||||
for response_event in response_events {
|
|
||||||
yield Ok(response_event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(message) => {
|
|
||||||
yield Err(Arc::new(AIApiError::Stream {
|
|
||||||
stream_type: "acp",
|
|
||||||
source: anyhow::anyhow!(message),
|
|
||||||
}));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if steering_result.is_terminated() {
|
|
||||||
steering_result = pending_steering(session.clone(), steering);
|
|
||||||
} else {
|
|
||||||
steering_queue.push_back(steering);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
steering = steering_result => {
|
|
||||||
match steering {
|
|
||||||
Ok(AcpSteeringOutcome::Injected) => {
|
|
||||||
// The user message was rendered as soon as Galaxy
|
|
||||||
// accepted it; keep consuming agent events without
|
|
||||||
// holding the transcript behind the steering RPC.
|
|
||||||
}
|
|
||||||
Ok(AcpSteeringOutcome::StartedNewTurn) => {
|
|
||||||
for response_event in translator.steering_started_new_turn() {
|
|
||||||
yield Ok(response_event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(AcpSteeringOutcome::Failed) => {
|
|
||||||
for response_event in translator.steering_failed(
|
|
||||||
"the ACP agent could not inject it into the active turn",
|
|
||||||
) {
|
|
||||||
yield Ok(response_event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
log::warn!("ACP live steering failed: {error}");
|
|
||||||
for response_event in translator.steering_failed(&error.to_string()) {
|
|
||||||
yield Ok(response_event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
steering_result = Fuse::terminated();
|
|
||||||
if let Some(steering) = steering_queue.pop_front() {
|
|
||||||
steering_result = pending_steering(session.clone(), steering);
|
|
||||||
} else if events.is_terminated() {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
event = events.next() => {
|
event = events.next() => {
|
||||||
let Some(event) = event else {
|
let Some(event) = event else {
|
||||||
if steering_result.is_terminated() && steering_queue.is_empty() {
|
break;
|
||||||
|
};
|
||||||
|
let event = match event {
|
||||||
|
Ok(event) => event,
|
||||||
|
Err(error) => {
|
||||||
|
yield Err(Arc::new(AIApiError::Stream {
|
||||||
|
stream_type: "acp",
|
||||||
|
source: anyhow::anyhow!(error),
|
||||||
|
}));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
};
|
};
|
||||||
if let AcpEvent::SessionStarted {
|
|
||||||
session_id,
|
|
||||||
can_load,
|
|
||||||
can_steer,
|
|
||||||
..
|
|
||||||
} = &event
|
|
||||||
{
|
|
||||||
if let Ok(mut metadata) = session_metadata.lock() {
|
|
||||||
metadata.session_id = Some(session_id.to_string());
|
|
||||||
metadata.can_load = *can_load;
|
|
||||||
metadata.can_steer = *can_steer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let AcpEvent::ConfigOptions { options } = &event {
|
|
||||||
if let Ok(mut metadata) = session_metadata.lock() {
|
|
||||||
metadata.config_options = options.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match translator.translate(event) {
|
match translator.translate(event) {
|
||||||
Ok(response_events) => {
|
Ok(response_events) => {
|
||||||
for response_event in response_events {
|
for response_event in response_events {
|
||||||
yield Ok(response_event);
|
yield Ok(api::StreamEvent::Response(response_event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
@@ -279,18 +179,32 @@ pub(crate) fn acp_startup_error_stream(
|
|||||||
fn response_translator(
|
fn response_translator(
|
||||||
params: &RequestParams,
|
params: &RequestParams,
|
||||||
backend: &AcpConversationData,
|
backend: &AcpConversationData,
|
||||||
) -> AcpResponseTranslator {
|
) -> RuntimeResponseTranslator {
|
||||||
let task_id = params
|
let task_id = params
|
||||||
.root_task_id
|
.root_task_id
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||||
let user_query = request_user_query(params);
|
let user_query = request_user_query(params);
|
||||||
AcpResponseTranslator::new(
|
RuntimeResponseTranslator::new(RuntimeResponseConfig {
|
||||||
task_id,
|
task_id,
|
||||||
params.tasks.is_empty(),
|
// ACP owns its session identifier. Keeping this empty prevents the
|
||||||
|
// compatibility Init event from entering Galaxy cloud-token paths.
|
||||||
|
conversation_id: String::new(),
|
||||||
|
needs_create_task: params.tasks.is_empty(),
|
||||||
user_query,
|
user_query,
|
||||||
acp_selection_identity(&backend.agent_id, &backend.config_values),
|
model_id: if backend.provider_id.is_empty() {
|
||||||
)
|
acp_selection_identity(&backend.agent_id, &backend.config_values)
|
||||||
|
} else {
|
||||||
|
acp_provider_selection_identity(
|
||||||
|
&backend.provider_id,
|
||||||
|
&backend.agent_id,
|
||||||
|
&backend.config_values,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
max_context_tokens: None,
|
||||||
|
capabilities: RuntimeCapabilities::session_runtime(),
|
||||||
|
empty_output_message: Some("> ACP agent completed without a text response.".to_owned()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_user_query(params: &RequestParams) -> Option<String> {
|
fn request_user_query(params: &RequestParams) -> Option<String> {
|
||||||
@@ -359,11 +273,16 @@ fn galaxy_mcp_args(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn translated_startup_error_stream(
|
fn translated_startup_error_stream(
|
||||||
mut translator: AcpResponseTranslator,
|
mut translator: RuntimeResponseTranslator,
|
||||||
message: &str,
|
message: &str,
|
||||||
) -> api::ResponseStream {
|
) -> api::ResponseStream {
|
||||||
let events = translator.startup_error(message);
|
let events =
|
||||||
Box::pin(futures::stream::iter(events.into_iter().map(Ok)))
|
translator.startup_error(&format!("Galaxy couldn't start the ACP agent: {message}"));
|
||||||
|
Box::pin(futures::stream::iter(
|
||||||
|
events
|
||||||
|
.into_iter()
|
||||||
|
.map(|event| Ok(api::StreamEvent::Response(event))),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+56
-25
@@ -14,13 +14,14 @@ pub use convert_from::{
|
|||||||
MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError,
|
MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError,
|
||||||
};
|
};
|
||||||
use futures_lite::Stream;
|
use futures_lite::Stream;
|
||||||
|
use galaxy_agent_core::ToolResult;
|
||||||
use galaxy_core::channel::ChannelState;
|
use galaxy_core::channel::ChannelState;
|
||||||
use galaxy_core::execution_mode::AppExecutionMode;
|
use galaxy_core::execution_mode::AppExecutionMode;
|
||||||
use galaxy_core::features::FeatureFlag;
|
use galaxy_core::features::FeatureFlag;
|
||||||
use galaxy_core::user_preferences::GetUserPreferences;
|
use galaxy_core::user_preferences::GetUserPreferences;
|
||||||
use galaxyui::{AppContext, EntityId, SingletonEntity as _};
|
use galaxyui::{AppContext, EntityId, SingletonEntity as _};
|
||||||
use mcp::TemplatableMCPServerInfo;
|
use mcp::TemplatableMCPServerInfo;
|
||||||
pub use r#impl::generate_multi_agent_output;
|
pub(crate) use r#impl::prepare_direct_provider_params;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use super::{AIAgentInput, MCPContext, MCPServer, RequestMetadata, Suggestions};
|
use super::{AIAgentInput, MCPContext, MCPServer, RequestMetadata, Suggestions};
|
||||||
@@ -36,6 +37,21 @@ use crate::settings::AISettings;
|
|||||||
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
|
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
|
||||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||||
|
|
||||||
|
const INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA: &str =
|
||||||
|
"galaxy:internal-command-completion-assessment:v1";
|
||||||
|
|
||||||
|
pub(crate) fn mark_internal_command_completion_assessment(
|
||||||
|
message: &mut warp_multi_agent_api::Message,
|
||||||
|
) {
|
||||||
|
message.server_message_data = INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_internal_command_completion_assessment(
|
||||||
|
message: &warp_multi_agent_api::Message,
|
||||||
|
) -> bool {
|
||||||
|
message.server_message_data == INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA
|
||||||
|
}
|
||||||
|
|
||||||
/// Unique, server-generated conversation-scoped token to be roundtripped to the API when sending
|
/// Unique, server-generated conversation-scoped token to be roundtripped to the API when sending
|
||||||
/// requests that follow-up within a given conversation.
|
/// requests that follow-up within a given conversation.
|
||||||
#[derive(Serialize, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Serialize, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
@@ -96,6 +112,8 @@ pub struct RequestParams {
|
|||||||
/// locally so ACP-provided Galaxy tools can be pinned to the exact pane.
|
/// locally so ACP-provided Galaxy tools can be pinned to the exact pane.
|
||||||
pub terminal_view_id: Option<EntityId>,
|
pub terminal_view_id: Option<EntityId>,
|
||||||
pub input: Vec<AIAgentInput>,
|
pub input: Vec<AIAgentInput>,
|
||||||
|
/// Normalized action results appended to direct-provider run history.
|
||||||
|
pub tool_results: Vec<ToolResult>,
|
||||||
pub conversation_token: Option<ServerConversationToken>,
|
pub conversation_token: Option<ServerConversationToken>,
|
||||||
pub forked_from_conversation_token: Option<ServerConversationToken>,
|
pub forked_from_conversation_token: Option<ServerConversationToken>,
|
||||||
pub ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
pub ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
||||||
@@ -133,44 +151,55 @@ pub struct RequestParams {
|
|||||||
pub research_agent_enabled: bool,
|
pub research_agent_enabled: bool,
|
||||||
pub orchestration_enabled: bool,
|
pub orchestration_enabled: bool,
|
||||||
pub supported_tools_override: Option<Vec<warp_multi_agent_api::ToolType>>,
|
pub supported_tools_override: Option<Vec<warp_multi_agent_api::ToolType>>,
|
||||||
/// The root task ID for the conversation — needed for direct Bedrock streaming
|
/// The root task ID used to anchor direct-provider projection when optimistic tasks are not
|
||||||
/// since optimistic tasks don't appear in the proto task_context.
|
/// present in the proto task context.
|
||||||
pub root_task_id: Option<String>,
|
pub root_task_id: Option<String>,
|
||||||
/// The conversation ID of the parent agent that spawned this child agent, if any.
|
/// The conversation ID of the parent agent that spawned this child agent, if any.
|
||||||
pub parent_agent_id: Option<String>,
|
pub parent_agent_id: Option<String>,
|
||||||
/// The display name for this agent (e.g. "Agent 1"), assigned by the orchestrator.
|
/// The display name for this agent (e.g. "Agent 1"), assigned by the orchestrator.
|
||||||
pub agent_name: Option<String>,
|
pub agent_name: Option<String>,
|
||||||
/// Full Bedrock conversation history for direct Bedrock calls.
|
/// Provider-neutral conversation history for direct model calls.
|
||||||
/// When present, the Bedrock path uses this instead of extracting from task_context.
|
pub message_history: Vec<crate::ai::provider::types::ConversationMessage>,
|
||||||
pub bedrock_message_history: Vec<crate::ai::bedrock::convert::ConversationMessage>,
|
|
||||||
/// Progressive summary of older conversation history. Prepended as the first
|
/// Progressive summary of older conversation history. Prepended as the first
|
||||||
/// message pair in the messages array sent to Bedrock.
|
/// message pair in the messages array sent to the model.
|
||||||
pub bedrock_progressive_summary: Option<String>,
|
pub progressive_summary: Option<String>,
|
||||||
/// Archived tool_use/tool_result pairs from previous summarization drains.
|
/// Archived tool_use/tool_result pairs from previous summarization drains.
|
||||||
/// Passed to the Bedrock translator so `recall_tool_history` can search archived
|
/// Kept separately so `recall_tool_history` can search archived results even after
|
||||||
/// results even after they've been summarized away from live history.
|
/// they've been summarized away from live history.
|
||||||
pub bedrock_tool_result_archive: Vec<crate::ai::bedrock::convert::ConversationMessage>,
|
pub tool_result_archive: Vec<crate::ai::provider::types::ConversationMessage>,
|
||||||
/// Populated by the Bedrock path after building the message list.
|
/// Populated while preparing a direct-provider run with the durable transcript that the
|
||||||
/// Contains the full messages sent (old history + new input) so the controller
|
/// controller persists for restoration and future turns.
|
||||||
/// can store them back into the conversation for the next request cycle.
|
pub messages_sent:
|
||||||
pub bedrock_messages_sent:
|
std::sync::Arc<std::sync::Mutex<Vec<crate::ai::provider::types::ConversationMessage>>>,
|
||||||
std::sync::Arc<std::sync::Mutex<Vec<crate::ai::bedrock::convert::ConversationMessage>>>,
|
|
||||||
/// Global rules (name, content) from the local CloudModel (AIFact/AIMemory).
|
/// Global rules (name, content) from the local CloudModel (AIFact/AIMemory).
|
||||||
/// Injected into the system prompt when `is_memory_enabled` is true.
|
/// Injected into the system prompt when `is_memory_enabled` is true.
|
||||||
pub global_rules: Vec<(String, String)>,
|
pub global_rules: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Event = Result<warp_multi_agent_api::ResponseEvent, Arc<AIApiError>>;
|
/// Response event projected into the local conversation controller.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum StreamEvent {
|
||||||
|
Response(warp_multi_agent_api::ResponseEvent),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Event = Result<StreamEvent, Arc<AIApiError>>;
|
||||||
|
pub type LegacyEvent = Result<warp_multi_agent_api::ResponseEvent, Arc<AIApiError>>;
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
pub type ResponseStream = Pin<Box<dyn Stream<Item = Event> + Send + 'static>>;
|
pub type ResponseStream = Pin<Box<dyn Stream<Item = Event> + Send + 'static>>;
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
pub type LegacyResponseStream = Pin<Box<dyn Stream<Item = LegacyEvent> + Send + 'static>>;
|
||||||
|
|
||||||
// The WASM version of this type has no bound on `Send`, which is an unnecessary bound when
|
// The WASM version of this type has no bound on `Send`, which is an unnecessary bound when
|
||||||
// targeting wasm because the browser is single-threaded (and we don't leverage WebWorkers for async
|
// targeting wasm because the browser is single-threaded (and we don't leverage WebWorkers for async
|
||||||
// execution in WoW).
|
// execution in WoW).
|
||||||
#[cfg(target_family = "wasm")]
|
#[cfg(target_family = "wasm")]
|
||||||
pub type ResponseStream = Pin<Box<dyn Stream<Item = Event>>>;
|
pub type ResponseStream = Pin<Box<dyn Stream<Item = Event>>>;
|
||||||
|
|
||||||
|
#[cfg(target_family = "wasm")]
|
||||||
|
pub type LegacyResponseStream = Pin<Box<dyn Stream<Item = LegacyEvent>>>;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ConversationData {
|
pub struct ConversationData {
|
||||||
pub id: AIConversationId,
|
pub id: AIConversationId,
|
||||||
@@ -187,6 +216,7 @@ impl RequestParams {
|
|||||||
Self {
|
Self {
|
||||||
terminal_view_id: None,
|
terminal_view_id: None,
|
||||||
input: vec![],
|
input: vec![],
|
||||||
|
tool_results: vec![],
|
||||||
conversation_token: None,
|
conversation_token: None,
|
||||||
forked_from_conversation_token: None,
|
forked_from_conversation_token: None,
|
||||||
ambient_agent_task_id: None,
|
ambient_agent_task_id: None,
|
||||||
@@ -218,10 +248,10 @@ impl RequestParams {
|
|||||||
parent_agent_id: None,
|
parent_agent_id: None,
|
||||||
agent_name: None,
|
agent_name: None,
|
||||||
root_task_id: None,
|
root_task_id: None,
|
||||||
bedrock_message_history: vec![],
|
message_history: vec![],
|
||||||
bedrock_progressive_summary: None,
|
progressive_summary: None,
|
||||||
bedrock_tool_result_archive: vec![],
|
tool_result_archive: vec![],
|
||||||
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(vec![])),
|
messages_sent: std::sync::Arc::new(std::sync::Mutex::new(vec![])),
|
||||||
global_rules: vec![],
|
global_rules: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,6 +421,7 @@ impl RequestParams {
|
|||||||
Self {
|
Self {
|
||||||
terminal_view_id,
|
terminal_view_id,
|
||||||
input: request_input.all_inputs().cloned().collect(),
|
input: request_input.all_inputs().cloned().collect(),
|
||||||
|
tool_results: Vec::new(),
|
||||||
conversation_token: conversation.server_conversation_token,
|
conversation_token: conversation.server_conversation_token,
|
||||||
forked_from_conversation_token: conversation.forked_from_conversation_token,
|
forked_from_conversation_token: conversation.forked_from_conversation_token,
|
||||||
ambient_agent_task_id: conversation.ambient_agent_task_id,
|
ambient_agent_task_id: conversation.ambient_agent_task_id,
|
||||||
@@ -426,10 +457,10 @@ impl RequestParams {
|
|||||||
.map(|id| id.to_string()),
|
.map(|id| id.to_string()),
|
||||||
parent_agent_id: None,
|
parent_agent_id: None,
|
||||||
agent_name: None,
|
agent_name: None,
|
||||||
bedrock_message_history: Vec::new(),
|
message_history: Vec::new(),
|
||||||
bedrock_progressive_summary: None,
|
progressive_summary: None,
|
||||||
bedrock_tool_result_archive: Vec::new(),
|
tool_result_archive: Vec::new(),
|
||||||
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||||
global_rules: if is_memory_enabled {
|
global_rules: if is_memory_enabled {
|
||||||
Self::load_global_rules(app)
|
Self::load_global_rules(app)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use crate::ai::agent::api::convert_from::{
|
|||||||
convert_user_query_mode, ConversionParams, ConvertAPIMessageToClientOutputMessage,
|
convert_user_query_mode, ConversionParams, ConvertAPIMessageToClientOutputMessage,
|
||||||
MaybeAIAgentOutputMessage,
|
MaybeAIAgentOutputMessage,
|
||||||
};
|
};
|
||||||
|
use crate::ai::agent::api::is_internal_command_completion_assessment;
|
||||||
use crate::ai::agent::conversation::{
|
use crate::ai::agent::conversation::{
|
||||||
update_todo_list_from_todo_op, AIConversation, AIConversationId, ServerAIConversationMetadata,
|
update_todo_list_from_todo_op, AIConversation, AIConversationId, ServerAIConversationMetadata,
|
||||||
};
|
};
|
||||||
@@ -72,6 +73,7 @@ pub fn convert_conversation_data_to_ai_conversation(
|
|||||||
let agent_conversation_data = match restoration_mode {
|
let agent_conversation_data = match restoration_mode {
|
||||||
RestorationMode::Fork => AgentConversationData {
|
RestorationMode::Fork => AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
|
active_provider_run_json: None,
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: usage_metadata,
|
conversation_usage_metadata: usage_metadata,
|
||||||
reverted_action_ids: None,
|
reverted_action_ids: None,
|
||||||
@@ -95,6 +97,7 @@ pub fn convert_conversation_data_to_ai_conversation(
|
|||||||
},
|
},
|
||||||
RestorationMode::Continue => AgentConversationData {
|
RestorationMode::Continue => AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
|
active_provider_run_json: None,
|
||||||
server_conversation_token: Some(
|
server_conversation_token: Some(
|
||||||
metadata.server_conversation_token.as_str().to_string(),
|
metadata.server_conversation_token.as_str().to_string(),
|
||||||
),
|
),
|
||||||
@@ -389,17 +392,21 @@ impl ConvertToExchanges for &api::Task {
|
|||||||
|
|
||||||
let added_message_as_exchange_input = match message {
|
let added_message_as_exchange_input = match message {
|
||||||
api::message::Message::UserQuery(user_query) => {
|
api::message::Message::UserQuery(user_query) => {
|
||||||
// Add user query as input
|
if is_internal_command_completion_assessment(api_message) {
|
||||||
current_inputs.push(AIAgentInput::UserQuery {
|
false
|
||||||
|
} else {
|
||||||
|
// Add user query as input
|
||||||
|
current_inputs.push(AIAgentInput::UserQuery {
|
||||||
query: user_query.query.clone(),
|
query: user_query.query.clone(),
|
||||||
context: convert_input_context(user_query.context.as_ref()),
|
context: convert_input_context(user_query.context.as_ref()),
|
||||||
static_query_type: None,
|
static_query_type: None,
|
||||||
referenced_attachments: HashMap::new(),
|
referenced_attachments: HashMap::new(),
|
||||||
user_query_mode: convert_user_query_mode(user_query.mode.as_ref()),
|
user_query_mode: convert_user_query_mode(user_query.mode.as_ref()),
|
||||||
running_command: None,
|
running_command: None,
|
||||||
intended_agent: Some(user_query.intended_agent()),
|
intended_agent: Some(user_query.intended_agent()),
|
||||||
});
|
});
|
||||||
true
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
api::message::Message::SystemQuery(query) => {
|
api::message::Message::SystemQuery(query) => {
|
||||||
let Some(query_type) = &query.r#type else {
|
let Some(query_type) = &query.r#type else {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use chrono::Utc;
|
|||||||
use warp_multi_agent_api as api;
|
use warp_multi_agent_api as api;
|
||||||
|
|
||||||
use crate::ai::agent::api::convert_conversation::*;
|
use crate::ai::agent::api::convert_conversation::*;
|
||||||
use crate::ai::agent::api::ServerConversationToken;
|
use crate::ai::agent::api::{mark_internal_command_completion_assessment, ServerConversationToken};
|
||||||
use crate::ai::agent::conversation::{
|
use crate::ai::agent::conversation::{
|
||||||
AIAgentHarness, AIConversationId, ServerAIConversationMetadata,
|
AIAgentHarness, AIConversationId, ServerAIConversationMetadata,
|
||||||
};
|
};
|
||||||
@@ -2129,6 +2129,61 @@ fn test_create_then_edit_then_create_version_tracking() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_internal_command_completion_assessment_restores_output_without_visible_input() {
|
||||||
|
let assessment_text =
|
||||||
|
"[Completed command: cargo test]\n[Final terminal output:\ntest result: ok\n]";
|
||||||
|
let mut hidden_assessment = api::Message {
|
||||||
|
id: "msg_assessment".to_string(),
|
||||||
|
task_id: "task1".to_string(),
|
||||||
|
request_id: "req1".to_string(),
|
||||||
|
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
|
||||||
|
query: assessment_text.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
mark_internal_command_completion_assessment(&mut hidden_assessment);
|
||||||
|
let provider_history =
|
||||||
|
crate::ai::bedrock::request_translator::convert_proto_message(&hidden_assessment)
|
||||||
|
.expect("hidden assessment should remain in provider history");
|
||||||
|
assert_eq!(
|
||||||
|
provider_history.role,
|
||||||
|
crate::ai::provider::types::MessageRole::User
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
provider_history.content,
|
||||||
|
crate::ai::provider::types::MessageContent::Text(text) if text == assessment_text
|
||||||
|
));
|
||||||
|
|
||||||
|
let task = api::Task {
|
||||||
|
id: "task1".to_string(),
|
||||||
|
messages: vec![
|
||||||
|
hidden_assessment,
|
||||||
|
api::Message {
|
||||||
|
id: "msg_output".to_string(),
|
||||||
|
task_id: "task1".to_string(),
|
||||||
|
request_id: "req1".to_string(),
|
||||||
|
message: Some(api::message::Message::AgentOutput(
|
||||||
|
api::message::AgentOutput {
|
||||||
|
text: "The command completed successfully.".to_string(),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let exchanges = task.into_exchanges();
|
||||||
|
assert_eq!(exchanges.len(), 1);
|
||||||
|
assert!(exchanges[0].input.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
exchanges[0].format_output_for_copy(None),
|
||||||
|
"The command completed successfully."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Verify that a `SystemQuery::HandoffRehydration` message does not produce
|
/// Verify that a `SystemQuery::HandoffRehydration` message does not produce
|
||||||
/// a displayed input when restoring a conversation. It must be treated as
|
/// a displayed input when restoring a conversation. It must be treated as
|
||||||
/// hidden, so the exchange should have zero user-visible inputs.
|
/// hidden, so the exchange should have zero user-visible inputs.
|
||||||
|
|||||||
@@ -16,16 +16,18 @@ use warp_multi_agent_api as api;
|
|||||||
use crate::ai::agent::api::convert_conversation::{
|
use crate::ai::agent::api::convert_conversation::{
|
||||||
convert_input_context, convert_tool_call_result_to_input,
|
convert_input_context, convert_tool_call_result_to_input,
|
||||||
};
|
};
|
||||||
|
use crate::ai::agent::api::is_internal_command_completion_assessment;
|
||||||
use crate::ai::agent::comment::CodeReview;
|
use crate::ai::agent::comment::CodeReview;
|
||||||
use crate::ai::agent::task::TaskId;
|
use crate::ai::agent::task::TaskId;
|
||||||
use crate::ai::agent::todos::AIAgentTodoList;
|
use crate::ai::agent::todos::AIAgentTodoList;
|
||||||
use crate::ai::agent::util::parse_markdown_into_text_and_code_sections;
|
use crate::ai::agent::util::parse_markdown_into_text_and_code_sections;
|
||||||
use crate::ai::agent::{
|
use crate::ai::agent::{
|
||||||
AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation, AIAgentInput,
|
runtime_activity, AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation,
|
||||||
AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData, CloneRepositoryURL,
|
AIAgentInput, AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData,
|
||||||
MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest,
|
CloneRepositoryURL, MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode,
|
||||||
StartAgentExecutionMode, SubagentCall, SubagentType, SuggestedAgentModeWorkflow, SuggestedRule,
|
RunAgentsRequest, StartAgentExecutionMode, SubagentCall, SubagentType,
|
||||||
Suggestions, SummarizationType, TodoOperation, UserQueryMode, WebFetchStatus, WebSearchStatus,
|
SuggestedAgentModeWorkflow, SuggestedRule, Suggestions, SummarizationType, TodoOperation,
|
||||||
|
UserQueryMode, WebFetchStatus, WebSearchStatus,
|
||||||
};
|
};
|
||||||
use crate::ai::artifact_download::sanitized_basename;
|
use crate::ai::artifact_download::sanitized_basename;
|
||||||
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
|
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
|
||||||
@@ -272,10 +274,18 @@ impl ConvertAPIMessageToClientOutputMessage for api::Message {
|
|||||||
.collect::<Result<Vec<AIAgentCitation>, UnknownCitationTypeError>>()?;
|
.collect::<Result<Vec<AIAgentCitation>, UnknownCitationTypeError>>()?;
|
||||||
|
|
||||||
match message {
|
match message {
|
||||||
api::message::Message::AgentOutput(output) => Ok(MaybeAIAgentOutputMessage::Message(
|
api::message::Message::AgentOutput(output) => {
|
||||||
AIAgentOutputMessage::text(MessageId::new(self.id), output.into())
|
let message = if let Some(activity) =
|
||||||
.with_citations(citations),
|
runtime_activity::decode(&self.server_message_data)
|
||||||
)),
|
{
|
||||||
|
AIAgentOutputMessage::runtime_activity(MessageId::new(self.id), activity)
|
||||||
|
} else {
|
||||||
|
AIAgentOutputMessage::text(MessageId::new(self.id), output.into())
|
||||||
|
};
|
||||||
|
Ok(MaybeAIAgentOutputMessage::Message(
|
||||||
|
message.with_citations(citations),
|
||||||
|
))
|
||||||
|
}
|
||||||
api::message::Message::AgentReasoning(reasoning) => {
|
api::message::Message::AgentReasoning(reasoning) => {
|
||||||
let duration = reasoning
|
let duration = reasoning
|
||||||
.finished_duration
|
.finished_duration
|
||||||
@@ -948,6 +958,9 @@ pub fn user_inputs_from_messages(messages: &[api::Message]) -> Vec<AIAgentInput>
|
|||||||
let Some(inner) = &m.message else { continue };
|
let Some(inner) = &m.message else { continue };
|
||||||
match inner {
|
match inner {
|
||||||
api::message::Message::UserQuery(uq) => {
|
api::message::Message::UserQuery(uq) => {
|
||||||
|
if is_internal_command_completion_assessment(m) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let context = convert_input_context(uq.context.as_ref());
|
let context = convert_input_context(uq.context.as_ref());
|
||||||
let referenced_attachments = uq
|
let referenced_attachments = uq
|
||||||
.referenced_attachments
|
.referenced_attachments
|
||||||
|
|||||||
@@ -2,16 +2,19 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use ai::agent::action::AskUserQuestionType;
|
use ai::agent::action::AskUserQuestionType;
|
||||||
use ai::skills::{SkillPathOrigin, SkillReference};
|
use ai::skills::{SkillPathOrigin, SkillReference};
|
||||||
|
use galaxy_agent_core::{RuntimeActivity, RuntimeActivityStatus};
|
||||||
use warp_multi_agent_api as api;
|
use warp_multi_agent_api as api;
|
||||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
convert_api_question, ConversionParams, ConvertAPIMessageToClientOutputMessage,
|
convert_api_question, user_inputs_from_messages, ConversionParams,
|
||||||
MaybeAIAgentOutputMessage,
|
ConvertAPIMessageToClientOutputMessage, MaybeAIAgentOutputMessage,
|
||||||
};
|
};
|
||||||
|
use crate::ai::agent::api::mark_internal_command_completion_assessment;
|
||||||
use crate::ai::agent::task::TaskId;
|
use crate::ai::agent::task::TaskId;
|
||||||
use crate::ai::agent::{
|
use crate::ai::agent::{
|
||||||
AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType, StartAgentExecutionMode,
|
runtime_activity, AIAgentActionType, AIAgentInput, AIAgentOutputMessageType,
|
||||||
|
LifecycleEventType, StartAgentExecutionMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn start_agent_tool_call_message(
|
fn start_agent_tool_call_message(
|
||||||
@@ -615,6 +618,36 @@ fn converts_local_start_agent_v2_with_harness_type() {
|
|||||||
assert_eq!(lifecycle_subscription, None);
|
assert_eq!(lifecycle_subscription, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn internal_command_completion_assessment_is_not_restored_as_shared_user_input() {
|
||||||
|
let mut hidden_assessment = api::Message {
|
||||||
|
id: "hidden-assessment".to_string(),
|
||||||
|
task_id: "task".to_string(),
|
||||||
|
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
|
||||||
|
query: "[Completed command: cargo test]".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
mark_internal_command_completion_assessment(&mut hidden_assessment);
|
||||||
|
let visible_query = api::Message {
|
||||||
|
id: "visible-query".to_string(),
|
||||||
|
task_id: "task".to_string(),
|
||||||
|
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
|
||||||
|
query: "What changed?".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let inputs = user_inputs_from_messages(&[hidden_assessment, visible_query]);
|
||||||
|
assert_eq!(inputs.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
&inputs[0],
|
||||||
|
AIAgentInput::UserQuery { query, .. } if query == "What changed?"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transfer_control_tool_call_converts_to_action_message() {
|
fn transfer_control_tool_call_converts_to_action_message() {
|
||||||
let task_id = TaskId::new("task".to_string());
|
let task_id = TaskId::new("task".to_string());
|
||||||
@@ -665,3 +698,45 @@ fn transfer_control_tool_call_converts_to_action_message() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_runtime_activity_converts_to_display_only_output() {
|
||||||
|
let activity = RuntimeActivity {
|
||||||
|
id: "tool-1".to_owned(),
|
||||||
|
title: "List directories".to_owned(),
|
||||||
|
status: Some(RuntimeActivityStatus::Completed),
|
||||||
|
output: Some("payments\nrecords_v2".to_owned()),
|
||||||
|
};
|
||||||
|
let task_id = TaskId::new("task".to_owned());
|
||||||
|
let message = api::Message {
|
||||||
|
fetched_memories: Vec::new(),
|
||||||
|
id: "message".to_owned(),
|
||||||
|
task_id: "task".to_owned(),
|
||||||
|
server_message_data: runtime_activity::encode(&activity).expect("metadata"),
|
||||||
|
citations: Vec::new(),
|
||||||
|
message: Some(api::message::Message::AgentOutput(
|
||||||
|
api::message::AgentOutput {
|
||||||
|
text: "fallback text".to_owned(),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
request_id: "request".to_owned(),
|
||||||
|
timestamp: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let converted = message
|
||||||
|
.to_client_output_message(ConversionParams {
|
||||||
|
task_id: &task_id,
|
||||||
|
current_todo_list: None,
|
||||||
|
active_code_review: None,
|
||||||
|
skill_path_origin: &SkillPathOrigin::Local,
|
||||||
|
})
|
||||||
|
.expect("conversion");
|
||||||
|
|
||||||
|
let MaybeAIAgentOutputMessage::Message(output) = converted else {
|
||||||
|
panic!("expected display output");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
output.message,
|
||||||
|
AIAgentOutputMessageType::RuntimeActivity(activity)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -348,6 +348,43 @@ fn convert_input_to_user_input(
|
|||||||
}
|
}
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
AIAgentInput::CommandCompletionAssessment {
|
||||||
|
prompt,
|
||||||
|
completed_command:
|
||||||
|
RunningCommand {
|
||||||
|
command,
|
||||||
|
block_id,
|
||||||
|
grid_contents: output,
|
||||||
|
cursor,
|
||||||
|
requested_command_id,
|
||||||
|
is_alt_screen_active,
|
||||||
|
},
|
||||||
|
..
|
||||||
|
} => Ok(
|
||||||
|
api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
|
||||||
|
api::request::input::CliAgentUserQuery {
|
||||||
|
user_query: Some(api::request::input::UserQuery {
|
||||||
|
query: prompt,
|
||||||
|
referenced_attachments: Default::default(),
|
||||||
|
mode: Some(UserQueryMode::Normal.into()),
|
||||||
|
intended_agent: api::AgentType::Primary.into(),
|
||||||
|
}),
|
||||||
|
running_command: Some(api::RunningShellCommand {
|
||||||
|
command,
|
||||||
|
snapshot: Some(api::LongRunningShellCommandSnapshot {
|
||||||
|
output,
|
||||||
|
cursor,
|
||||||
|
command_id: block_id.as_str().to_owned(),
|
||||||
|
is_alt_screen_active,
|
||||||
|
is_preempted: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
run_shell_command_tool_call_id: requested_command_id
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
AIAgentInput::ActionResult { result, .. } => result.try_into(),
|
AIAgentInput::ActionResult { result, .. } => result.try_into(),
|
||||||
AIAgentInput::MessagesReceivedFromAgents { messages } => Ok(
|
AIAgentInput::MessagesReceivedFromAgents { messages } => Ok(
|
||||||
api::request::input::user_inputs::user_input::Input::MessagesReceivedFromAgents(
|
api::request::input::user_inputs::user_input::Input::MessagesReceivedFromAgents(
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use galaxy_core::command::ExitCode;
|
use galaxy_core::command::ExitCode;
|
||||||
use warp_multi_agent_api as api;
|
use warp_multi_agent_api as api;
|
||||||
|
|
||||||
use crate::ai::agent::task::TaskId;
|
use crate::ai::agent::task::TaskId;
|
||||||
use crate::ai::agent::{
|
use crate::ai::agent::{
|
||||||
AIAgentActionResult, AIAgentActionResultType, AIAgentContext, ImageContext,
|
AIAgentActionResult, AIAgentActionResultType, AIAgentContext, AIAgentInput, ImageContext,
|
||||||
TransferShellCommandControlToUserResult,
|
RunningCommand, TransferShellCommandControlToUserResult, UserQueryMode,
|
||||||
};
|
};
|
||||||
use crate::terminal::model::block::BlockId;
|
use crate::terminal::model::block::BlockId;
|
||||||
|
|
||||||
@@ -132,6 +134,84 @@ fn git_context_deserializes_legacy_string_pull_request_number() {
|
|||||||
assert_eq!(pull_request.number, 42);
|
assert_eq!(pull_request.number, 42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_completion_assessment_converts_to_primary_cli_query() {
|
||||||
|
let block_id = BlockId::from("completed-block".to_string());
|
||||||
|
let converted = super::convert_input(vec![AIAgentInput::CommandCompletionAssessment {
|
||||||
|
prompt: "Summarize whether the command succeeded.".to_string(),
|
||||||
|
context: Arc::from([AIAgentContext::SelectedText("root context".to_string())]),
|
||||||
|
completed_command: RunningCommand {
|
||||||
|
command: "cargo test -p galaxy".to_string(),
|
||||||
|
block_id: block_id.clone(),
|
||||||
|
grid_contents: "test result: ok".to_string(),
|
||||||
|
cursor: "cursor".to_string(),
|
||||||
|
requested_command_id: Some("run-call".to_string().into()),
|
||||||
|
is_alt_screen_active: true,
|
||||||
|
},
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let Some(api::request::input::Type::UserInputs(inputs)) = converted.r#type else {
|
||||||
|
panic!("expected user inputs");
|
||||||
|
};
|
||||||
|
let Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(query)) =
|
||||||
|
inputs.inputs[0].input.as_ref()
|
||||||
|
else {
|
||||||
|
panic!("expected CLI agent query");
|
||||||
|
};
|
||||||
|
let user_query = query
|
||||||
|
.user_query
|
||||||
|
.as_ref()
|
||||||
|
.expect("expected assessment prompt");
|
||||||
|
assert_eq!(user_query.query, "Summarize whether the command succeeded.");
|
||||||
|
assert_eq!(user_query.intended_agent(), api::AgentType::Primary);
|
||||||
|
let command = query
|
||||||
|
.running_command
|
||||||
|
.as_ref()
|
||||||
|
.expect("expected completed command");
|
||||||
|
assert_eq!(command.command, "cargo test -p galaxy");
|
||||||
|
let snapshot = command.snapshot.as_ref().expect("expected final snapshot");
|
||||||
|
assert_eq!(snapshot.command_id, block_id.as_str());
|
||||||
|
assert_eq!(snapshot.output, "test result: ok");
|
||||||
|
assert_eq!(snapshot.cursor, "cursor");
|
||||||
|
assert!(snapshot.is_alt_screen_active);
|
||||||
|
assert_eq!(query.run_shell_command_tool_call_id, "run-call");
|
||||||
|
|
||||||
|
let active = super::convert_input(vec![AIAgentInput::UserQuery {
|
||||||
|
query: "Keep monitoring.".to_string(),
|
||||||
|
context: Arc::from([]),
|
||||||
|
static_query_type: None,
|
||||||
|
referenced_attachments: Default::default(),
|
||||||
|
user_query_mode: UserQueryMode::Normal,
|
||||||
|
running_command: Some(RunningCommand {
|
||||||
|
command: "cargo test -p galaxy".to_string(),
|
||||||
|
block_id,
|
||||||
|
grid_contents: "still running".to_string(),
|
||||||
|
cursor: String::new(),
|
||||||
|
requested_command_id: None,
|
||||||
|
is_alt_screen_active: false,
|
||||||
|
}),
|
||||||
|
intended_agent: None,
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
let Some(api::request::input::Type::UserInputs(inputs)) = active.r#type else {
|
||||||
|
panic!("expected active user inputs");
|
||||||
|
};
|
||||||
|
let Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(query)) =
|
||||||
|
inputs.inputs[0].input.as_ref()
|
||||||
|
else {
|
||||||
|
panic!("expected active CLI agent query");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
query
|
||||||
|
.user_query
|
||||||
|
.as_ref()
|
||||||
|
.expect("expected active monitor prompt")
|
||||||
|
.intended_agent(),
|
||||||
|
api::AgentType::Cli
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transfer_control_snapshot_result_converts_to_tool_call_result_input() {
|
fn transfer_control_snapshot_result_converts_to_tool_call_result_input() {
|
||||||
let block_id = BlockId::default();
|
let block_id = BlockId::default();
|
||||||
|
|||||||
+33
-204
@@ -1,217 +1,43 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use galaxy_core::features::FeatureFlag;
|
use galaxy_core::features::FeatureFlag;
|
||||||
use warp_multi_agent_api as api;
|
use warp_multi_agent_api as api;
|
||||||
|
|
||||||
use super::convert_to::convert_input;
|
use super::RequestParams;
|
||||||
use super::{ConvertToAPITypeError, RequestParams, ResponseStream};
|
|
||||||
use crate::ai::agent::redaction;
|
use crate::ai::agent::redaction;
|
||||||
use crate::ai::openai::translator as openai_translator;
|
|
||||||
use crate::ai::provider::ProviderConfig;
|
|
||||||
use crate::server::server_api::AIApiError;
|
|
||||||
use crate::terminal::model::session::SessionType;
|
use crate::terminal::model::session::SessionType;
|
||||||
|
|
||||||
pub async fn generate_multi_agent_output(
|
fn remove_orchestration_tools_if_disabled(
|
||||||
provider_config: ProviderConfig,
|
supported_tools: &mut Vec<api::ToolType>,
|
||||||
mut params: RequestParams,
|
orchestration_enabled: bool,
|
||||||
cancellation_rx: futures::channel::oneshot::Receiver<()>,
|
) {
|
||||||
) -> Result<ResponseStream, ConvertToAPITypeError> {
|
if orchestration_enabled {
|
||||||
let supported_tools_override = params.supported_tools_override.take();
|
return;
|
||||||
let supported_tools = supported_tools_override
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| get_supported_tools(¶ms));
|
|
||||||
let supported_cli_agent_tools =
|
|
||||||
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(¶ms));
|
|
||||||
let mut logging_metadata = HashMap::new();
|
|
||||||
if let Some(metadata) = params.metadata {
|
|
||||||
logging_metadata.insert(
|
|
||||||
"is_autodetected_user_query".to_owned(),
|
|
||||||
prost_types::Value {
|
|
||||||
kind: Some(prost_types::value::Kind::BoolValue(
|
|
||||||
metadata.is_autodetected_user_query,
|
|
||||||
)),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
logging_metadata.insert(
|
|
||||||
"entrypoint".to_owned(),
|
|
||||||
prost_types::Value {
|
|
||||||
kind: Some(prost_types::value::Kind::StringValue(
|
|
||||||
metadata.entrypoint.entrypoint(),
|
|
||||||
)),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
logging_metadata.insert(
|
|
||||||
"is_auto_resume_after_error".to_owned(),
|
|
||||||
prost_types::Value {
|
|
||||||
kind: Some(prost_types::value::Kind::BoolValue(
|
|
||||||
metadata.is_auto_resume_after_error,
|
|
||||||
)),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
supported_tools.retain(|tool| {
|
||||||
|
!matches!(
|
||||||
|
tool,
|
||||||
|
api::ToolType::Subagent | api::ToolType::RunAgents | api::ToolType::StartAgentV2
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prepare_direct_provider_params(
|
||||||
|
params: &mut RequestParams,
|
||||||
|
) -> (Vec<api::ToolType>, Vec<api::ToolType>) {
|
||||||
|
let supported_tools_override = params.supported_tools_override.take();
|
||||||
|
let mut supported_tools = supported_tools_override
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| get_supported_tools(params));
|
||||||
|
remove_orchestration_tools_if_disabled(&mut supported_tools, params.orchestration_enabled);
|
||||||
|
let mut supported_cli_agent_tools =
|
||||||
|
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(params));
|
||||||
|
remove_orchestration_tools_if_disabled(
|
||||||
|
&mut supported_cli_agent_tools,
|
||||||
|
params.orchestration_enabled,
|
||||||
|
);
|
||||||
if params.should_redact_secrets {
|
if params.should_redact_secrets {
|
||||||
redaction::redact_inputs(&mut params.input);
|
redaction::redact_inputs(&mut params.input);
|
||||||
}
|
}
|
||||||
|
(supported_tools, supported_cli_agent_tools)
|
||||||
let mut request = api::Request {
|
|
||||||
task_context: Some(api::request::TaskContext {
|
|
||||||
tasks: params.tasks,
|
|
||||||
}),
|
|
||||||
input: Some(convert_input(params.input)?),
|
|
||||||
settings: Some(api::request::Settings {
|
|
||||||
model_config: Some(api::request::settings::ModelConfig {
|
|
||||||
base: params.model.clone().into(),
|
|
||||||
cli_agent: params.cli_agent_model.clone().into(),
|
|
||||||
computer_use_agent: params.computer_use_model.clone().into(),
|
|
||||||
base_model_context_window_limit: params.context_window_limit.unwrap_or(0),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
rules_enabled: params.is_memory_enabled,
|
|
||||||
warp_drive_context_enabled: params.warp_drive_context_enabled,
|
|
||||||
web_context_retrieval_enabled: true,
|
|
||||||
supports_parallel_tool_calls: true,
|
|
||||||
use_anthropic_text_editor_tools: false,
|
|
||||||
planning_enabled: params.planning_enabled,
|
|
||||||
supports_create_files: true,
|
|
||||||
supported_tools: supported_tools.into_iter().map(Into::into).collect(),
|
|
||||||
supports_long_running_commands: true,
|
|
||||||
should_preserve_file_content_in_history: true,
|
|
||||||
supports_todos_ui: true,
|
|
||||||
supports_linked_code_blocks: FeatureFlag::LinkedCodeBlocks.is_enabled(),
|
|
||||||
supports_started_child_task_message: true,
|
|
||||||
// Galaxy's direct providers only receive tools with local schemas and
|
|
||||||
// executors. Hosted-only suggestion/orchestration capability bits must
|
|
||||||
// remain false so models do not plan around unavailable Warp services.
|
|
||||||
supports_suggest_prompt: false,
|
|
||||||
supports_read_image_files: FeatureFlag::ReadImageFiles.is_enabled(),
|
|
||||||
supports_reasoning_message: true,
|
|
||||||
api_keys: params.api_keys,
|
|
||||||
autonomy_level: params.autonomy_level.into(),
|
|
||||||
isolation_level: params.isolation_level.into(),
|
|
||||||
web_search_enabled: params.web_search_enabled,
|
|
||||||
supported_cli_agent_tools: supported_cli_agent_tools
|
|
||||||
.into_iter()
|
|
||||||
.map(Into::into)
|
|
||||||
.collect(),
|
|
||||||
supports_v4a_file_diffs: FeatureFlag::V4AFileDiffs.is_enabled(),
|
|
||||||
supports_summarization_via_message_replacement:
|
|
||||||
FeatureFlag::SummarizationViaMessageReplacement.is_enabled(),
|
|
||||||
supports_bundled_skills: FeatureFlag::BundledSkills.is_enabled(),
|
|
||||||
supports_research_agent: params.research_agent_enabled,
|
|
||||||
supports_orchestration_v2: false,
|
|
||||||
supports_background_computer_use: FeatureFlag::BackgroundComputerUse.is_enabled()
|
|
||||||
&& computer_use::background_supported(),
|
|
||||||
custom_model_providers: params.custom_model_providers,
|
|
||||||
custom_model_routers: params.custom_model_routers,
|
|
||||||
}),
|
|
||||||
metadata: Some(api::request::Metadata {
|
|
||||||
logging: logging_metadata,
|
|
||||||
conversation_id: params
|
|
||||||
.conversation_token
|
|
||||||
.as_ref()
|
|
||||||
.map(|token| token.as_str().to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
ambient_agent_task_id: params
|
|
||||||
.ambient_agent_task_id
|
|
||||||
.map(|id| id.to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
forked_from_conversation_id: if params.conversation_token.is_none() {
|
|
||||||
// We only include this param on our initial request to the server
|
|
||||||
// (when the forked conversation has not been assigned a new id yet).
|
|
||||||
params
|
|
||||||
.forked_from_conversation_token
|
|
||||||
.map(|token| token.as_str().to_string())
|
|
||||||
.unwrap_or_default()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
},
|
|
||||||
parent_agent_id: params.parent_agent_id.unwrap_or_default(),
|
|
||||||
agent_name: params.agent_name.unwrap_or_default(),
|
|
||||||
}),
|
|
||||||
existing_suggestions: params
|
|
||||||
.existing_suggestions
|
|
||||||
.map(|suggestions| suggestions.into()),
|
|
||||||
mcp_context: params.mcp_context.map(Into::into),
|
|
||||||
};
|
|
||||||
|
|
||||||
match provider_config {
|
|
||||||
ProviderConfig::OpenAI(config) => {
|
|
||||||
let translator_request = openai_translator::TranslatorRequest {
|
|
||||||
config,
|
|
||||||
model_id: params.model.as_str().to_string(),
|
|
||||||
root_task_id: params.root_task_id.clone(),
|
|
||||||
message_history: params.bedrock_message_history.clone(),
|
|
||||||
tool_result_archive: params.bedrock_tool_result_archive.clone(),
|
|
||||||
progressive_summary: params.bedrock_progressive_summary.clone(),
|
|
||||||
messages_sent: params.bedrock_messages_sent.clone(),
|
|
||||||
global_rules: params.global_rules.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match openai_translator::execute(translator_request, &mut request).await {
|
|
||||||
Ok(stream) => {
|
|
||||||
let output_stream = stream.take_until(cancellation_rx);
|
|
||||||
Ok(Box::pin(output_stream))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("[openai] Translator error: {e}");
|
|
||||||
let err = Arc::new(
|
|
||||||
crate::server::server_api::AIApiError::Stream {
|
|
||||||
stream_type: "openai_chat_completions",
|
|
||||||
source: anyhow::anyhow!("{e}"),
|
|
||||||
}
|
|
||||||
.into_quota_limit_if_provider_budget_exhausted(),
|
|
||||||
);
|
|
||||||
let (tx, rx) = async_channel::unbounded();
|
|
||||||
let _ = tx.send(Err(err)).await;
|
|
||||||
Ok(Box::pin(rx))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProviderConfig::Bedrock(config) => {
|
|
||||||
let translator_request = crate::ai::bedrock::translator::TranslatorRequest {
|
|
||||||
config,
|
|
||||||
model_id: params.model.as_str().to_string(),
|
|
||||||
root_task_id: params.root_task_id.clone(),
|
|
||||||
bedrock_message_history: params.bedrock_message_history.clone(),
|
|
||||||
bedrock_tool_result_archive: params.bedrock_tool_result_archive.clone(),
|
|
||||||
bedrock_progressive_summary: params.bedrock_progressive_summary.clone(),
|
|
||||||
bedrock_messages_sent: params.bedrock_messages_sent.clone(),
|
|
||||||
global_rules: params.global_rules.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match crate::ai::bedrock::translator::execute(translator_request, &mut request).await {
|
|
||||||
Ok(stream) => {
|
|
||||||
let output_stream = stream.take_until(cancellation_rx);
|
|
||||||
Ok(Box::pin(output_stream))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("[bedrock] Translator error: {e}");
|
|
||||||
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
|
|
||||||
stream_type: "bedrock",
|
|
||||||
source: anyhow::anyhow!("{e}"),
|
|
||||||
});
|
|
||||||
let (tx, rx) = async_channel::unbounded();
|
|
||||||
let _ = tx.send(Err(err)).await;
|
|
||||||
Ok(Box::pin(rx))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProviderConfig::None => {
|
|
||||||
// No provider configured — do not fall back to Warp's cloud API.
|
|
||||||
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
|
|
||||||
stream_type: "none",
|
|
||||||
source: anyhow::anyhow!(
|
|
||||||
"No AI provider configured. Enable Bedrock or OpenAI/LiteLLM in settings."
|
|
||||||
),
|
|
||||||
});
|
|
||||||
let (tx, rx) = async_channel::unbounded();
|
|
||||||
let _ = tx.send(Err(err)).await;
|
|
||||||
Ok(Box::pin(rx))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
|
fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
|
||||||
@@ -222,7 +48,6 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
|
|||||||
api::ToolType::ReadMcpResource,
|
api::ToolType::ReadMcpResource,
|
||||||
api::ToolType::CallMcpTool,
|
api::ToolType::CallMcpTool,
|
||||||
api::ToolType::RunShellCommand,
|
api::ToolType::RunShellCommand,
|
||||||
api::ToolType::Subagent,
|
|
||||||
api::ToolType::WriteToLongRunningShellCommand,
|
api::ToolType::WriteToLongRunningShellCommand,
|
||||||
api::ToolType::ReadShellCommandOutput,
|
api::ToolType::ReadShellCommandOutput,
|
||||||
api::ToolType::ReadDocuments,
|
api::ToolType::ReadDocuments,
|
||||||
@@ -230,6 +55,10 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
|
|||||||
api::ToolType::EditDocuments,
|
api::ToolType::EditDocuments,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if params.orchestration_enabled {
|
||||||
|
supported_tools.push(api::ToolType::Subagent);
|
||||||
|
}
|
||||||
|
|
||||||
if FeatureFlag::ConversationsAsContext.is_enabled() {
|
if FeatureFlag::ConversationsAsContext.is_enabled() {
|
||||||
supported_tools.push(api::ToolType::FetchConversation);
|
supported_tools.push(api::ToolType::FetchConversation);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ use galaxy_core::features::FeatureFlag;
|
|||||||
use galaxy_core::HostId;
|
use galaxy_core::HostId;
|
||||||
use warp_multi_agent_api as api;
|
use warp_multi_agent_api as api;
|
||||||
|
|
||||||
use super::{get_supported_cli_agent_tools, get_supported_tools};
|
use super::{
|
||||||
|
get_supported_cli_agent_tools, get_supported_tools, remove_orchestration_tools_if_disabled,
|
||||||
|
};
|
||||||
use crate::ai::agent::api::RequestParams;
|
use crate::ai::agent::api::RequestParams;
|
||||||
use crate::ai::blocklist::SessionContext;
|
use crate::ai::blocklist::SessionContext;
|
||||||
use crate::ai::llms::LLMId;
|
use crate::ai::llms::LLMId;
|
||||||
@@ -14,6 +16,7 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
|
|||||||
RequestParams {
|
RequestParams {
|
||||||
terminal_view_id: None,
|
terminal_view_id: None,
|
||||||
input: vec![],
|
input: vec![],
|
||||||
|
tool_results: vec![],
|
||||||
conversation_token: None,
|
conversation_token: None,
|
||||||
forked_from_conversation_token: None,
|
forked_from_conversation_token: None,
|
||||||
ambient_agent_task_id: None,
|
ambient_agent_task_id: None,
|
||||||
@@ -45,10 +48,10 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
|
|||||||
root_task_id: None,
|
root_task_id: None,
|
||||||
parent_agent_id: None,
|
parent_agent_id: None,
|
||||||
agent_name: None,
|
agent_name: None,
|
||||||
bedrock_message_history: Vec::new(),
|
message_history: Vec::new(),
|
||||||
bedrock_progressive_summary: None,
|
progressive_summary: None,
|
||||||
bedrock_tool_result_archive: Vec::new(),
|
tool_result_archive: Vec::new(),
|
||||||
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||||
global_rules: Vec::new(),
|
global_rules: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +79,50 @@ fn supported_tools_expose_local_subagents_without_hosted_orchestration_tools() {
|
|||||||
assert!(!supported_tools.contains(&api::ToolType::StartAgentV2));
|
assert!(!supported_tools.contains(&api::ToolType::StartAgentV2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supported_tools_include_plan_document_capabilities() {
|
||||||
|
let params = request_params_with_ask_user_question_enabled(false);
|
||||||
|
let supported_tools = get_supported_tools(¶ms);
|
||||||
|
|
||||||
|
assert!(supported_tools.contains(&api::ToolType::ReadDocuments));
|
||||||
|
assert!(supported_tools.contains(&api::ToolType::CreateDocuments));
|
||||||
|
assert!(supported_tools.contains(&api::ToolType::EditDocuments));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supported_tools_omit_subagents_when_orchestration_is_disabled() {
|
||||||
|
let params = request_params_with_ask_user_question_enabled(false);
|
||||||
|
let supported_tools = get_supported_tools(¶ms);
|
||||||
|
|
||||||
|
assert!(!supported_tools.contains(&api::ToolType::Subagent));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supported_tool_override_cannot_restore_leaf_orchestration_tools() {
|
||||||
|
let mut supported_tools = vec![
|
||||||
|
api::ToolType::Grep,
|
||||||
|
api::ToolType::Subagent,
|
||||||
|
api::ToolType::RunAgents,
|
||||||
|
api::ToolType::StartAgentV2,
|
||||||
|
];
|
||||||
|
|
||||||
|
remove_orchestration_tools_if_disabled(&mut supported_tools, false);
|
||||||
|
|
||||||
|
assert_eq!(supported_tools, vec![api::ToolType::Grep]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enabled_orchestration_preserves_supported_tool_override() {
|
||||||
|
let mut supported_tools = vec![api::ToolType::Grep, api::ToolType::Subagent];
|
||||||
|
|
||||||
|
remove_orchestration_tools_if_disabled(&mut supported_tools, true);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
supported_tools,
|
||||||
|
vec![api::ToolType::Grep, api::ToolType::Subagent]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn supported_tools_omit_hosted_only_capabilities() {
|
fn supported_tools_omit_hosted_only_capabilities() {
|
||||||
let params = request_params_with_ask_user_question_enabled(false);
|
let params = request_params_with_ask_user_question_enabled(false);
|
||||||
|
|||||||
@@ -229,6 +229,9 @@ pub struct AIConversation {
|
|||||||
/// Runtime responsible for executing this conversation.
|
/// Runtime responsible for executing this conversation.
|
||||||
agent_backend: AgentBackend,
|
agent_backend: AgentBackend,
|
||||||
|
|
||||||
|
/// Opaque, versioned snapshot of the active direct-provider run.
|
||||||
|
active_provider_run_json: Option<String>,
|
||||||
|
|
||||||
/// The server-generated unique "token" for this conversation.
|
/// The server-generated unique "token" for this conversation.
|
||||||
///
|
///
|
||||||
/// This must be roundtripped to the server when sending follow-ups within a given conversation.
|
/// This must be roundtripped to the server when sending follow-ups within a given conversation.
|
||||||
@@ -380,6 +383,7 @@ impl AIConversation {
|
|||||||
has_opened_code_review: false,
|
has_opened_code_review: false,
|
||||||
conversation_usage_metadata: ConversationUsageMetadata::default(),
|
conversation_usage_metadata: ConversationUsageMetadata::default(),
|
||||||
agent_backend,
|
agent_backend,
|
||||||
|
active_provider_run_json: None,
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
task_id: None,
|
task_id: None,
|
||||||
forked_from_server_conversation_token: None,
|
forked_from_server_conversation_token: None,
|
||||||
@@ -539,6 +543,7 @@ impl AIConversation {
|
|||||||
|
|
||||||
let (
|
let (
|
||||||
agent_backend,
|
agent_backend,
|
||||||
|
active_provider_run_json,
|
||||||
server_conversation_token,
|
server_conversation_token,
|
||||||
forked_from_server_conversation_token,
|
forked_from_server_conversation_token,
|
||||||
conversation_usage_metadata,
|
conversation_usage_metadata,
|
||||||
@@ -589,6 +594,7 @@ impl AIConversation {
|
|||||||
};
|
};
|
||||||
(
|
(
|
||||||
data.agent_backend,
|
data.agent_backend,
|
||||||
|
data.active_provider_run_json,
|
||||||
server_conversation_token,
|
server_conversation_token,
|
||||||
forked_from_server_conversation_token,
|
forked_from_server_conversation_token,
|
||||||
conversation_usage_metadata,
|
conversation_usage_metadata,
|
||||||
@@ -611,6 +617,7 @@ impl AIConversation {
|
|||||||
AgentBackend::default(),
|
AgentBackend::default(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
ConversationUsageMetadata::default(),
|
ConversationUsageMetadata::default(),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -663,6 +670,7 @@ impl AIConversation {
|
|||||||
has_opened_code_review: false,
|
has_opened_code_review: false,
|
||||||
conversation_usage_metadata,
|
conversation_usage_metadata,
|
||||||
agent_backend,
|
agent_backend,
|
||||||
|
active_provider_run_json,
|
||||||
server_conversation_token,
|
server_conversation_token,
|
||||||
task_id: run_id.as_deref().and_then(|id| id.parse().ok()),
|
task_id: run_id.as_deref().and_then(|id| id.parse().ok()),
|
||||||
forked_from_server_conversation_token,
|
forked_from_server_conversation_token,
|
||||||
@@ -705,6 +713,35 @@ impl AIConversation {
|
|||||||
&self.agent_backend
|
&self.agent_backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn active_provider_run_json(&self) -> Option<&str> {
|
||||||
|
self.active_provider_run_json.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_active_provider_run_json(&mut self, snapshot: Option<String>) {
|
||||||
|
self.active_provider_run_json = snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the backend of a conversation that has not produced agent output.
|
||||||
|
///
|
||||||
|
/// Provider failures without output are safe to retry through a newly enabled runtime. Once
|
||||||
|
/// any exchange has produced output, the backend remains stable so provider-native and
|
||||||
|
/// ACP-owned histories are never mixed.
|
||||||
|
pub(crate) fn set_agent_backend_if_no_output(&mut self, agent_backend: AgentBackend) -> bool {
|
||||||
|
let can_change_backend = self.all_exchanges().iter().all(|exchange| {
|
||||||
|
matches!(
|
||||||
|
&exchange.output_status,
|
||||||
|
AIAgentOutputStatus::Finished {
|
||||||
|
finished_output: FinishedAIAgentOutput::Error { output: None, .. }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if !can_change_backend && self.agent_backend != agent_backend {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.agent_backend = agent_backend;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Records a resumable ACP session ID.
|
/// Records a resumable ACP session ID.
|
||||||
///
|
///
|
||||||
/// Returns `false` when called for a native provider conversation.
|
/// Returns `false` when called for a native provider conversation.
|
||||||
@@ -1893,7 +1930,8 @@ impl AIConversation {
|
|||||||
) -> String {
|
) -> String {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for exchange in self.all_exchanges() {
|
for exchange in self.all_exchanges() {
|
||||||
let formatted_exchange = exchange.format_for_copy(action_model);
|
let formatted_exchange =
|
||||||
|
exchange.format_for_copy_for_conversation(action_model, Some(self.id()));
|
||||||
if !formatted_exchange.is_empty() {
|
if !formatted_exchange.is_empty() {
|
||||||
result.push(formatted_exchange);
|
result.push(formatted_exchange);
|
||||||
}
|
}
|
||||||
@@ -1971,21 +2009,25 @@ impl AIConversation {
|
|||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn contains_action(&self, action_id: &AIAgentActionId) -> bool {
|
pub fn action(&self, action_id: &AIAgentActionId) -> Option<AIAgentAction> {
|
||||||
self.task_store.tasks().any(|task| {
|
self.task_store.tasks().find_map(|task| {
|
||||||
task.exchanges()
|
task.exchanges().find_map(|exchange| {
|
||||||
.any(|exchange| {
|
let output = exchange.output_status.output()?;
|
||||||
let Some(output) = exchange.output_status.output()
|
output.get().messages.iter().find_map(|step| match step {
|
||||||
else {
|
AIAgentOutputMessage {
|
||||||
return false;
|
message: AIAgentOutputMessageType::Action(action),
|
||||||
};
|
..
|
||||||
output.get().messages.iter().any(|step| {
|
} if &action.id == action_id => Some(action.clone()),
|
||||||
matches!(step, AIAgentOutputMessage{ message: AIAgentOutputMessageType::Action(AIAgentAction { id, .. }), .. } if id == action_id)
|
AIAgentOutputMessage { .. } => None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn contains_action(&self, action_id: &AIAgentActionId) -> bool {
|
||||||
|
self.action(action_id).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the exchange ID that contains the given action ID, if any.
|
/// Returns the exchange ID that contains the given action ID, if any.
|
||||||
pub fn exchange_id_for_action(&self, action_id: &AIAgentActionId) -> Option<AIAgentExchangeId> {
|
pub fn exchange_id_for_action(&self, action_id: &AIAgentActionId) -> Option<AIAgentExchangeId> {
|
||||||
for task in self.task_store.tasks() {
|
for task in self.task_store.tasks() {
|
||||||
@@ -2088,6 +2130,81 @@ impl AIConversation {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reopens an exact restored exchange for continued provider projection.
|
||||||
|
///
|
||||||
|
/// This only restores the process-local stream association; it never adds input or provider
|
||||||
|
/// history, so the persisted provider run remains the sole continuation source of truth.
|
||||||
|
pub(crate) fn provider_projection_target(
|
||||||
|
&self,
|
||||||
|
response_stream_id: &ResponseStreamId,
|
||||||
|
) -> Option<(TaskId, AIAgentExchangeId)> {
|
||||||
|
let mut exchanges = self
|
||||||
|
.added_exchanges_by_response
|
||||||
|
.get(response_stream_id)?
|
||||||
|
.iter();
|
||||||
|
let target = exchanges.next()?;
|
||||||
|
exchanges
|
||||||
|
.next()
|
||||||
|
.is_none()
|
||||||
|
.then(|| (target.task_id.clone(), target.exchange_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rebind_provider_projection(
|
||||||
|
&mut self,
|
||||||
|
task_id: &TaskId,
|
||||||
|
exchange_id: AIAgentExchangeId,
|
||||||
|
response_stream_id: ResponseStreamId,
|
||||||
|
terminal_surface_id: EntityId,
|
||||||
|
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
|
||||||
|
) -> Result<(), UpdateConversationError> {
|
||||||
|
let Some(task) = self.task_store.get(task_id) else {
|
||||||
|
return Err(UpdateConversationError::TaskNotFound);
|
||||||
|
};
|
||||||
|
if !task.exchanges().any(|exchange| exchange.id == exchange_id) {
|
||||||
|
return if self.exchange_with_id(exchange_id).is_some() {
|
||||||
|
Err(UpdateConversationError::ExchangeTaskMismatch)
|
||||||
|
} else {
|
||||||
|
Err(UpdateConversationError::ExchangeNotFound)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.added_exchanges_by_response
|
||||||
|
.contains_key(&response_stream_id)
|
||||||
|
{
|
||||||
|
return Err(UpdateConversationError::ResponseStreamAlreadyBound);
|
||||||
|
}
|
||||||
|
|
||||||
|
let exchange = self.get_exchange_to_update(exchange_id)?;
|
||||||
|
let previous_status = std::mem::replace(
|
||||||
|
&mut exchange.output_status,
|
||||||
|
AIAgentOutputStatus::Streaming { output: None },
|
||||||
|
);
|
||||||
|
let output = match previous_status {
|
||||||
|
AIAgentOutputStatus::Streaming { output } => output,
|
||||||
|
AIAgentOutputStatus::Finished { finished_output } => match finished_output {
|
||||||
|
FinishedAIAgentOutput::Cancelled { output, .. }
|
||||||
|
| FinishedAIAgentOutput::Error { output, .. } => output,
|
||||||
|
FinishedAIAgentOutput::Success { output } => Some(output),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
exchange.output_status = AIAgentOutputStatus::Streaming { output };
|
||||||
|
exchange.finish_time = None;
|
||||||
|
self.added_exchanges_by_response.insert(
|
||||||
|
response_stream_id,
|
||||||
|
Vec1::new(AddedExchange {
|
||||||
|
task_id: task_id.clone(),
|
||||||
|
exchange_id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ctx.emit(BlocklistAIHistoryEvent::UpdatedStreamingExchange {
|
||||||
|
exchange_id,
|
||||||
|
terminal_surface_id,
|
||||||
|
conversation_id: self.id,
|
||||||
|
is_hidden: self.hidden_exchanges.contains(&exchange_id),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn append_reassigned_exchange(
|
pub fn append_reassigned_exchange(
|
||||||
&mut self,
|
&mut self,
|
||||||
response_stream_id: &ResponseStreamId,
|
response_stream_id: &ResponseStreamId,
|
||||||
@@ -2227,6 +2344,100 @@ impl AIConversation {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn apply_domain_tool_proposal(
|
||||||
|
&mut self,
|
||||||
|
stream_id: &ResponseStreamId,
|
||||||
|
terminal_surface_id: EntityId,
|
||||||
|
action: AIAgentAction,
|
||||||
|
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
|
||||||
|
) -> Result<(), UpdateConversationError> {
|
||||||
|
if self.contains_action(&action.id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let exchange_id = self.ensure_response_exchange_for_task(
|
||||||
|
stream_id,
|
||||||
|
&action.task_id,
|
||||||
|
terminal_surface_id,
|
||||||
|
ctx,
|
||||||
|
)?;
|
||||||
|
let message_id = MessageId::new(action.id.to_string());
|
||||||
|
let exchange = self.get_exchange_to_update(exchange_id)?;
|
||||||
|
match &exchange.output_status {
|
||||||
|
AIAgentOutputStatus::Streaming {
|
||||||
|
output: Some(output),
|
||||||
|
} => output
|
||||||
|
.get_mut()
|
||||||
|
.messages
|
||||||
|
.push(AIAgentOutputMessage::action(message_id, action)),
|
||||||
|
AIAgentOutputStatus::Streaming { output: None } => {
|
||||||
|
return Err(UpdateConversationError::OutputNeverInitialized);
|
||||||
|
}
|
||||||
|
AIAgentOutputStatus::Finished { .. } => {
|
||||||
|
return Err(UpdateConversationError::OutputAlreadyFinished);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.emit(BlocklistAIHistoryEvent::UpdatedStreamingExchange {
|
||||||
|
exchange_id,
|
||||||
|
terminal_surface_id,
|
||||||
|
conversation_id: self.id,
|
||||||
|
is_hidden: self.hidden_exchanges.contains(&exchange_id),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_response_exchange_for_task(
|
||||||
|
&mut self,
|
||||||
|
stream_id: &ResponseStreamId,
|
||||||
|
task_id: &TaskId,
|
||||||
|
terminal_surface_id: EntityId,
|
||||||
|
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
|
||||||
|
) -> Result<AIAgentExchangeId, UpdateConversationError> {
|
||||||
|
let added_exchanges = self
|
||||||
|
.added_exchanges_by_response
|
||||||
|
.get(stream_id)
|
||||||
|
.ok_or(UpdateConversationError::NoPendingRequest)?;
|
||||||
|
if let Some(exchange_id) = added_exchanges
|
||||||
|
.iter()
|
||||||
|
.find_map(|added| (added.task_id == *task_id).then_some(added.exchange_id))
|
||||||
|
{
|
||||||
|
return Ok(exchange_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct-provider command monitoring can switch tasks within one response stream. A
|
||||||
|
// tool-first monitor turn needs an exchange before any message event can create it.
|
||||||
|
let source_exchange = added_exchanges.last().clone();
|
||||||
|
let existing_exchange = self
|
||||||
|
.task_store
|
||||||
|
.get(&source_exchange.task_id)
|
||||||
|
.ok_or(UpdateConversationError::TaskNotFound)?
|
||||||
|
.exchange(source_exchange.exchange_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(UpdateConversationError::ExchangeNotFound)?;
|
||||||
|
let mut task = self
|
||||||
|
.task_store
|
||||||
|
.remove(task_id)
|
||||||
|
.ok_or(UpdateConversationError::TaskNotFound)?;
|
||||||
|
let exchange_id = task.append_new_exchange(&existing_exchange);
|
||||||
|
self.task_store.insert(task);
|
||||||
|
self.added_exchanges_by_response
|
||||||
|
.get_mut(stream_id)
|
||||||
|
.ok_or(UpdateConversationError::NoPendingRequest)?
|
||||||
|
.push(AddedExchange {
|
||||||
|
task_id: task_id.clone(),
|
||||||
|
exchange_id,
|
||||||
|
});
|
||||||
|
let is_hidden = self.hidden_exchanges.contains(&exchange_id);
|
||||||
|
ctx.emit(BlocklistAIHistoryEvent::AppendedExchange {
|
||||||
|
response_stream_id: Some(stream_id.clone()),
|
||||||
|
exchange_id,
|
||||||
|
task_id: task_id.clone(),
|
||||||
|
terminal_surface_id,
|
||||||
|
conversation_id: self.id,
|
||||||
|
is_hidden,
|
||||||
|
});
|
||||||
|
Ok(exchange_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_cost_and_usage_for_request(
|
pub fn update_cost_and_usage_for_request(
|
||||||
&mut self,
|
&mut self,
|
||||||
request_cost: Option<RequestCost>,
|
request_cost: Option<RequestCost>,
|
||||||
@@ -3824,6 +4035,7 @@ impl AIConversation {
|
|||||||
.collect(),
|
.collect(),
|
||||||
conversation_data: AgentConversationData {
|
conversation_data: AgentConversationData {
|
||||||
agent_backend: self.agent_backend.clone(),
|
agent_backend: self.agent_backend.clone(),
|
||||||
|
active_provider_run_json: self.active_provider_run_json.clone(),
|
||||||
server_conversation_token: self
|
server_conversation_token: self
|
||||||
.server_conversation_token
|
.server_conversation_token
|
||||||
.clone()
|
.clone()
|
||||||
@@ -4701,6 +4913,10 @@ fn cleanup_conversation_search_temp_dir(
|
|||||||
pub enum UpdateConversationError {
|
pub enum UpdateConversationError {
|
||||||
#[error("Exchange not found.")]
|
#[error("Exchange not found.")]
|
||||||
ExchangeNotFound,
|
ExchangeNotFound,
|
||||||
|
#[error("Exchange does not belong to the persisted task.")]
|
||||||
|
ExchangeTaskMismatch,
|
||||||
|
#[error("Response stream is already bound to an exchange.")]
|
||||||
|
ResponseStreamAlreadyBound,
|
||||||
#[error("Could not update task: {0:?}")]
|
#[error("Could not update task: {0:?}")]
|
||||||
UpdateTask(#[from] UpdateTaskError),
|
UpdateTask(#[from] UpdateTaskError),
|
||||||
#[error("Could not update upgrade optimistic task for server task: {0:?}")]
|
#[error("Could not update upgrade optimistic task for server task: {0:?}")]
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ fn restored_conversation_with_queries(queries: &[&str]) -> AIConversation {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_backend_does_not_change_after_successful_output() {
|
||||||
|
let mut conversation = restored_conversation_with_queries(&["Review this repository"]);
|
||||||
|
|
||||||
|
assert!(!conversation
|
||||||
|
.set_agent_backend_if_no_output(AgentBackend::Acp(AcpConversationData::default())));
|
||||||
|
assert_eq!(conversation.agent_backend(), &AgentBackend::Provider);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn latest_user_query_returns_latest_non_empty_user_query() {
|
fn latest_user_query_returns_latest_non_empty_user_query() {
|
||||||
let conversation =
|
let conversation =
|
||||||
@@ -184,6 +193,19 @@ fn restored_conversation_defaults_autoexecute_override_when_not_persisted() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restored_conversation_retains_active_provider_run_json() {
|
||||||
|
let snapshot = r#"{"version":1,"run":{"state":"awaiting_model"}}"#;
|
||||||
|
let conversation_data = AgentConversationData {
|
||||||
|
active_provider_run_json: Some(snapshot.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let conversation = restored_conversation(Some(conversation_data));
|
||||||
|
|
||||||
|
assert_eq!(conversation.active_provider_run_json(), Some(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restored_conversation_uses_persisted_last_event_sequence() {
|
fn restored_conversation_uses_persisted_last_event_sequence() {
|
||||||
let conversation_data: AgentConversationData =
|
let conversation_data: AgentConversationData =
|
||||||
@@ -219,6 +241,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
AgentBackend::Acp(AcpConversationData {
|
AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
agent_id: "codex-acp".to_string(),
|
agent_id: "codex-acp".to_string(),
|
||||||
launch_fingerprint: "launch-123".to_string(),
|
launch_fingerprint: "launch-123".to_string(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
@@ -229,6 +252,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
acp_conversation.agent_backend(),
|
acp_conversation.agent_backend(),
|
||||||
&AgentBackend::Acp(AcpConversationData {
|
&AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
agent_id: "codex-acp".to_string(),
|
agent_id: "codex-acp".to_string(),
|
||||||
launch_fingerprint: "launch-123".to_string(),
|
launch_fingerprint: "launch-123".to_string(),
|
||||||
session_id: Some("session-123".to_string()),
|
session_id: Some("session-123".to_string()),
|
||||||
@@ -247,6 +271,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn restored_conversation_uses_persisted_acp_backend() {
|
fn restored_conversation_uses_persisted_acp_backend() {
|
||||||
let backend = AgentBackend::Acp(AcpConversationData {
|
let backend = AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
agent_id: "codex-acp".to_string(),
|
agent_id: "codex-acp".to_string(),
|
||||||
launch_fingerprint: "launch-123".to_string(),
|
launch_fingerprint: "launch-123".to_string(),
|
||||||
session_id: Some("session-123".to_string()),
|
session_id: Some("session-123".to_string()),
|
||||||
|
|||||||
+80
-6
@@ -7,6 +7,7 @@ pub(crate) mod comment;
|
|||||||
pub(crate) mod icons;
|
pub(crate) mod icons;
|
||||||
pub(crate) mod linearization;
|
pub(crate) mod linearization;
|
||||||
pub(crate) mod redaction;
|
pub(crate) mod redaction;
|
||||||
|
pub(crate) mod runtime_activity;
|
||||||
pub(crate) mod task;
|
pub(crate) mod task;
|
||||||
mod task_store;
|
mod task_store;
|
||||||
pub(super) mod telemetry;
|
pub(super) mod telemetry;
|
||||||
@@ -27,6 +28,7 @@ use ai::skills::ParsedSkill;
|
|||||||
use chrono::{DateTime, Local, TimeDelta};
|
use chrono::{DateTime, Local, TimeDelta};
|
||||||
use comment::ReviewComment;
|
use comment::ReviewComment;
|
||||||
use derivative::Derivative;
|
use derivative::Derivative;
|
||||||
|
use galaxy_agent_core::RuntimeActivity;
|
||||||
use galaxy_core::channel::ChannelState;
|
use galaxy_core::channel::ChannelState;
|
||||||
use galaxy_core::features::FeatureFlag;
|
use galaxy_core::features::FeatureFlag;
|
||||||
use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline};
|
use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline};
|
||||||
@@ -579,6 +581,14 @@ impl AIAgentOutput {
|
|||||||
pub fn format_for_copy(
|
pub fn format_for_copy(
|
||||||
&self,
|
&self,
|
||||||
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
|
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
|
||||||
|
) -> String {
|
||||||
|
self.format_for_copy_for_conversation(action_model, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_for_copy_for_conversation(
|
||||||
|
&self,
|
||||||
|
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
|
||||||
|
conversation_id: Option<conversation::AIConversationId>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut last_was_action = false;
|
let mut last_was_action = false;
|
||||||
@@ -610,8 +620,12 @@ impl AIAgentOutput {
|
|||||||
}
|
}
|
||||||
AIAgentOutputMessageType::Action(action) => {
|
AIAgentOutputMessageType::Action(action) => {
|
||||||
// Include action results from the action model if available
|
// Include action results from the action model if available
|
||||||
if let Some(action_model) = action_model {
|
if let (Some(action_model), Some(conversation_id)) =
|
||||||
if let Some(action_result) = action_model.get_action_result(&action.id) {
|
(action_model, conversation_id)
|
||||||
|
{
|
||||||
|
if let Some(action_result) =
|
||||||
|
action_model.get_action_result(conversation_id, &action.id)
|
||||||
|
{
|
||||||
result.push(format!("{}", MarkdownActionResult(&action_result.result)));
|
result.push(format!("{}", MarkdownActionResult(&action_result.result)));
|
||||||
// Add an extra newline after tool call results for readability
|
// Add an extra newline after tool call results for readability
|
||||||
result.push(String::new());
|
result.push(String::new());
|
||||||
@@ -619,6 +633,13 @@ impl AIAgentOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AIAgentOutputMessageType::RuntimeActivity(activity) => {
|
||||||
|
result.push(activity.title.clone());
|
||||||
|
if let Some(output) = &activity.output {
|
||||||
|
result.push(output.clone());
|
||||||
|
}
|
||||||
|
last_was_action = true;
|
||||||
|
}
|
||||||
AIAgentOutputMessageType::TodoOperation(operation) => {
|
AIAgentOutputMessageType::TodoOperation(operation) => {
|
||||||
result.push(format!("{operation}"));
|
result.push(format!("{operation}"));
|
||||||
last_was_action = false;
|
last_was_action = false;
|
||||||
@@ -1213,6 +1234,9 @@ impl<'a> std::fmt::Display for MarkdownActionResult<'a> {
|
|||||||
RequestCommandOutputResult::CancelledBeforeExecution => {
|
RequestCommandOutputResult::CancelledBeforeExecution => {
|
||||||
write!(f, "\n_Command cancelled_")
|
write!(f, "\n_Command cancelled_")
|
||||||
}
|
}
|
||||||
|
RequestCommandOutputResult::ExecutionError { command, message } => {
|
||||||
|
write!(f, "\n_Command `{command}` was not executed: {message}_")
|
||||||
|
}
|
||||||
RequestCommandOutputResult::Denylisted { command } => {
|
RequestCommandOutputResult::Denylisted { command } => {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
@@ -1805,6 +1829,10 @@ pub enum AIAgentOutputMessageType {
|
|||||||
token_count: Option<u32>,
|
token_count: Option<u32>,
|
||||||
},
|
},
|
||||||
Subagent(SubagentCall),
|
Subagent(SubagentCall),
|
||||||
|
/// Display-only activity executed and owned by an external agent runtime.
|
||||||
|
/// Unlike [`AIAgentOutputMessageType::Action`], Galaxy must never dispatch
|
||||||
|
/// this activity through its action executor.
|
||||||
|
RuntimeActivity(RuntimeActivity),
|
||||||
Action(AIAgentAction),
|
Action(AIAgentAction),
|
||||||
TodoOperation(TodoOperation),
|
TodoOperation(TodoOperation),
|
||||||
WebSearch(WebSearchStatus),
|
WebSearch(WebSearchStatus),
|
||||||
@@ -1972,6 +2000,12 @@ impl Display for AIAgentOutputMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
AIAgentOutputMessageType::Action(action) => write!(f, "Action: {action}")?,
|
AIAgentOutputMessageType::Action(action) => write!(f, "Action: {action}")?,
|
||||||
|
AIAgentOutputMessageType::RuntimeActivity(activity) => {
|
||||||
|
write!(f, "Runtime activity: {}", activity.title)?;
|
||||||
|
if let Some(output) = &activity.output {
|
||||||
|
write!(f, "\n{output}")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
AIAgentOutputMessageType::TodoOperation(todo) => write!(f, "Todo: {todo}")?,
|
AIAgentOutputMessageType::TodoOperation(todo) => write!(f, "Todo: {todo}")?,
|
||||||
AIAgentOutputMessageType::Subagent(subagent) => write!(f, "Subagent: {subagent}")?,
|
AIAgentOutputMessageType::Subagent(subagent) => write!(f, "Subagent: {subagent}")?,
|
||||||
AIAgentOutputMessageType::WebSearch(status) => match status {
|
AIAgentOutputMessageType::WebSearch(status) => match status {
|
||||||
@@ -2044,6 +2078,14 @@ impl AIAgentOutputMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn runtime_activity(id: MessageId, activity: RuntimeActivity) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
message: AIAgentOutputMessageType::RuntimeActivity(activity),
|
||||||
|
citations: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn text(id: MessageId, text: AIAgentText) -> Self {
|
pub fn text(id: MessageId, text: AIAgentText) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
@@ -2678,6 +2720,13 @@ pub enum AIAgentInput {
|
|||||||
intended_agent: Option<AgentType>,
|
intended_agent: Option<AgentType>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// A hidden system turn that asks for the final assessment of a completed command.
|
||||||
|
CommandCompletionAssessment {
|
||||||
|
prompt: String,
|
||||||
|
context: Arc<[AIAgentContext]>,
|
||||||
|
completed_command: RunningCommand,
|
||||||
|
},
|
||||||
|
|
||||||
AutoCodeDiffQuery {
|
AutoCodeDiffQuery {
|
||||||
query: String,
|
query: String,
|
||||||
context: Arc<[AIAgentContext]>,
|
context: Arc<[AIAgentContext]>,
|
||||||
@@ -2842,6 +2891,9 @@ impl Display for AIAgentInput {
|
|||||||
Self::UserQuery { .. } => {
|
Self::UserQuery { .. } => {
|
||||||
write!(f, "UserQuery: {}", self.display_query().unwrap_or_default())
|
write!(f, "UserQuery: {}", self.display_query().unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
Self::CommandCompletionAssessment { .. } => {
|
||||||
|
write!(f, "CommandCompletionAssessment")
|
||||||
|
}
|
||||||
Self::AutoCodeDiffQuery { query, .. } => {
|
Self::AutoCodeDiffQuery { query, .. } => {
|
||||||
write!(f, "AutoCodeDiffQuery: {query}")
|
write!(f, "AutoCodeDiffQuery: {query}")
|
||||||
}
|
}
|
||||||
@@ -2930,7 +2982,8 @@ impl AIAgentInput {
|
|||||||
suggestion: PassiveSuggestionResultType::Prompt { prompt },
|
suggestion: PassiveSuggestionResultType::Prompt { prompt },
|
||||||
..
|
..
|
||||||
} => Some(prompt.clone()),
|
} => Some(prompt.clone()),
|
||||||
Self::AutoCodeDiffQuery { .. }
|
Self::CommandCompletionAssessment { .. }
|
||||||
|
| Self::AutoCodeDiffQuery { .. }
|
||||||
| Self::ActionResult { .. }
|
| Self::ActionResult { .. }
|
||||||
| Self::TriggerPassiveSuggestion { .. }
|
| Self::TriggerPassiveSuggestion { .. }
|
||||||
| Self::ResumeConversation { .. }
|
| Self::ResumeConversation { .. }
|
||||||
@@ -3021,6 +3074,7 @@ impl AIAgentInput {
|
|||||||
pub fn context(&self) -> Option<&[AIAgentContext]> {
|
pub fn context(&self) -> Option<&[AIAgentContext]> {
|
||||||
match self {
|
match self {
|
||||||
Self::UserQuery { context, .. }
|
Self::UserQuery { context, .. }
|
||||||
|
| Self::CommandCompletionAssessment { context, .. }
|
||||||
| Self::ActionResult { context, .. }
|
| Self::ActionResult { context, .. }
|
||||||
| Self::AutoCodeDiffQuery { context, .. }
|
| Self::AutoCodeDiffQuery { context, .. }
|
||||||
| Self::ResumeConversation { context, .. }
|
| Self::ResumeConversation { context, .. }
|
||||||
@@ -3054,7 +3108,8 @@ impl AIAgentInput {
|
|||||||
Some(res)
|
Some(res)
|
||||||
}
|
}
|
||||||
Self::TriggerPassiveSuggestion { attachments, .. } => Some(attachments.clone()),
|
Self::TriggerPassiveSuggestion { attachments, .. } => Some(attachments.clone()),
|
||||||
Self::ActionResult { .. }
|
Self::CommandCompletionAssessment { .. }
|
||||||
|
| Self::ActionResult { .. }
|
||||||
| Self::AutoCodeDiffQuery { .. }
|
| Self::AutoCodeDiffQuery { .. }
|
||||||
| Self::ResumeConversation { .. }
|
| Self::ResumeConversation { .. }
|
||||||
| Self::InitProjectRules { .. }
|
| Self::InitProjectRules { .. }
|
||||||
@@ -3185,9 +3240,19 @@ impl AIAgentExchange {
|
|||||||
pub fn format_output_for_copy(
|
pub fn format_output_for_copy(
|
||||||
&self,
|
&self,
|
||||||
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
|
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
|
||||||
|
) -> String {
|
||||||
|
self.format_output_for_copy_for_conversation(action_model, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_output_for_copy_for_conversation(
|
||||||
|
&self,
|
||||||
|
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
|
||||||
|
conversation_id: Option<conversation::AIConversationId>,
|
||||||
) -> String {
|
) -> String {
|
||||||
match self.output_status.output() {
|
match self.output_status.output() {
|
||||||
Some(output) => output.get().format_for_copy(action_model),
|
Some(output) => output
|
||||||
|
.get()
|
||||||
|
.format_for_copy_for_conversation(action_model, conversation_id),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3198,9 +3263,18 @@ impl AIAgentExchange {
|
|||||||
pub fn format_for_copy(
|
pub fn format_for_copy(
|
||||||
&self,
|
&self,
|
||||||
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
|
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
|
||||||
|
) -> String {
|
||||||
|
self.format_for_copy_for_conversation(action_model, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_for_copy_for_conversation(
|
||||||
|
&self,
|
||||||
|
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
|
||||||
|
conversation_id: Option<conversation::AIConversationId>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let input_text = self.format_input_for_copy();
|
let input_text = self.format_input_for_copy();
|
||||||
let output_text = self.format_output_for_copy(action_model);
|
let output_text =
|
||||||
|
self.format_output_for_copy_for_conversation(action_model, conversation_id);
|
||||||
let has_user_input = !input_text.is_empty();
|
let has_user_input = !input_text.is_empty();
|
||||||
let has_agent_output = !output_text.is_empty();
|
let has_agent_output = !output_text.is_empty();
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
|
use chrono::Local;
|
||||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||||
use warp_multi_agent_api::{FileContent, FileContentLineRange};
|
use warp_multi_agent_api::{FileContent, FileContentLineRange};
|
||||||
|
|
||||||
use crate::ai::agent::{
|
use crate::ai::agent::{
|
||||||
AIAgentContext, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText,
|
AIAgentContext, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutput,
|
||||||
|
AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentOutputStatus, AIAgentText,
|
||||||
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
|
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
|
||||||
AnyFileContent, FileContext, FormattedTextWrapper, MessageId, ProgrammingLanguage,
|
AnyFileContent, FileContext, FormattedTextWrapper, MessageId, ProgrammingLanguage,
|
||||||
RenderableAIError, TransientNetworkErrorKind,
|
RenderableAIError, RunningCommand, TransientNetworkErrorKind,
|
||||||
};
|
};
|
||||||
|
use crate::ai::llms::LLMId;
|
||||||
use crate::server::server_api::AIApiError;
|
use crate::server::server_api::AIApiError;
|
||||||
|
use crate::terminal::model::block::BlockId;
|
||||||
use crate::terminal::shell::ShellType;
|
use crate::terminal::shell::ShellType;
|
||||||
|
|
||||||
fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
|
fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
|
||||||
@@ -21,6 +26,51 @@ fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_completion_assessment_stays_hidden_from_user_transcript() {
|
||||||
|
let context: Arc<[AIAgentContext]> =
|
||||||
|
Arc::from([AIAgentContext::SelectedText("relevant context".to_string())]);
|
||||||
|
let input = AIAgentInput::CommandCompletionAssessment {
|
||||||
|
prompt: "Report the final result.".to_string(),
|
||||||
|
context: context.clone(),
|
||||||
|
completed_command: RunningCommand {
|
||||||
|
command: "cargo test -p galaxy".to_string(),
|
||||||
|
block_id: BlockId::from("completed-command".to_string()),
|
||||||
|
grid_contents: "test result: ok".to_string(),
|
||||||
|
cursor: String::new(),
|
||||||
|
requested_command_id: None,
|
||||||
|
is_alt_screen_active: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(input.display_query(), None);
|
||||||
|
assert!(!input.is_user_query());
|
||||||
|
assert!(!input.is_passive_request());
|
||||||
|
assert_eq!(input.context(), Some(context.as_ref()));
|
||||||
|
assert_eq!(input.attachments(), None);
|
||||||
|
|
||||||
|
let now = Local::now();
|
||||||
|
let exchange = AIAgentExchange {
|
||||||
|
id: AIAgentExchangeId::new(),
|
||||||
|
input: vec![input],
|
||||||
|
output_status: AIAgentOutputStatus::Streaming { output: None },
|
||||||
|
added_message_ids: HashSet::new(),
|
||||||
|
start_time: now,
|
||||||
|
finish_time: None,
|
||||||
|
time_to_first_token_ms: None,
|
||||||
|
working_directory: None,
|
||||||
|
model_id: LLMId::from("test-model"),
|
||||||
|
request_cost: None,
|
||||||
|
coding_model_id: LLMId::from("test-model"),
|
||||||
|
cli_agent_model_id: LLMId::from("test-model"),
|
||||||
|
computer_use_model_id: LLMId::from("test-model"),
|
||||||
|
response_initiator: None,
|
||||||
|
};
|
||||||
|
assert_eq!(exchange.format_input_for_copy(), "");
|
||||||
|
assert_eq!(exchange.format_for_copy(None), "");
|
||||||
|
assert!(!exchange.has_user_query());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn formatted_text_wrapper_shares_arc_across_calls() {
|
fn formatted_text_wrapper_shares_arc_across_calls() {
|
||||||
let text = FormattedText::new([FormattedTextLine::Line(vec![
|
let text = FormattedText::new([FormattedTextLine::Line(vec![
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
|
|||||||
redact_secrets(&mut running_command.cursor);
|
redact_secrets(&mut running_command.cursor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AIAgentInput::CommandCompletionAssessment {
|
||||||
|
prompt,
|
||||||
|
context,
|
||||||
|
completed_command,
|
||||||
|
} => {
|
||||||
|
redact_secrets(prompt);
|
||||||
|
redact_context(Arc::make_mut(context));
|
||||||
|
redact_secrets(&mut completed_command.command);
|
||||||
|
redact_secrets(&mut completed_command.grid_contents);
|
||||||
|
redact_secrets(&mut completed_command.cursor);
|
||||||
|
}
|
||||||
AIAgentInput::AutoCodeDiffQuery { query, context, .. } => {
|
AIAgentInput::AutoCodeDiffQuery { query, context, .. } => {
|
||||||
redact_secrets(query);
|
redact_secrets(query);
|
||||||
redact_context(Arc::make_mut(context));
|
redact_context(Arc::make_mut(context));
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
use galaxy_agent_core::RuntimeActivity;
|
||||||
|
|
||||||
|
const SERVER_MESSAGE_DATA_PREFIX: &str = "galaxy:runtime-activity:v1:";
|
||||||
|
|
||||||
|
pub(crate) fn encode(activity: &RuntimeActivity) -> Result<String, serde_json::Error> {
|
||||||
|
serde_json::to_string(activity).map(|json| format!("{SERVER_MESSAGE_DATA_PREFIX}{json}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode(server_message_data: &str) -> Option<RuntimeActivity> {
|
||||||
|
let json = server_message_data.strip_prefix(SERVER_MESSAGE_DATA_PREFIX)?;
|
||||||
|
serde_json::from_str(json).ok()
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use warp_multi_agent_api as api;
|
use warp_multi_agent_api as api;
|
||||||
|
|
||||||
@@ -360,6 +360,59 @@ impl TaskStore {
|
|||||||
append_refs_for_task(tasks, &mut refs, root_task);
|
append_refs_for_task(tasks, &mut refs, root_task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let indexed_task_ids = refs
|
||||||
|
.iter()
|
||||||
|
.map(|exchange_ref| exchange_ref.task_id.clone())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let mut direct_cli_tasks = tasks
|
||||||
|
.values()
|
||||||
|
.filter(|task| {
|
||||||
|
!indexed_task_ids.contains(task.id())
|
||||||
|
&& task.parent_id().as_ref() == Some(root_task_id)
|
||||||
|
&& task.is_cli_subagent()
|
||||||
|
&& task
|
||||||
|
.subagent_params()
|
||||||
|
.is_some_and(|params| params.tool_call_id.is_empty())
|
||||||
|
&& task.exchanges().next().is_some()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
direct_cli_tasks.sort_by(|left, right| {
|
||||||
|
left.exchanges()
|
||||||
|
.next()
|
||||||
|
.map(|exchange| exchange.start_time)
|
||||||
|
.cmp(&right.exchanges().next().map(|exchange| exchange.start_time))
|
||||||
|
.then_with(|| left.id().to_string().cmp(&right.id().to_string()))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Direct providers synthesize CLI monitor tasks without a parent Subagent message.
|
||||||
|
// Place each task as one chronological block so its exchanges remain reachable without
|
||||||
|
// changing the DFS order of server-linked subtasks.
|
||||||
|
for task in direct_cli_tasks {
|
||||||
|
let first_start_time = task
|
||||||
|
.exchanges()
|
||||||
|
.next()
|
||||||
|
.expect("direct CLI task was filtered to contain an exchange")
|
||||||
|
.start_time;
|
||||||
|
let insertion_index = refs
|
||||||
|
.iter()
|
||||||
|
.position(|exchange_ref| {
|
||||||
|
tasks
|
||||||
|
.get(&exchange_ref.task_id)
|
||||||
|
.and_then(|task| task.exchanges().nth(exchange_ref.exchange_index))
|
||||||
|
.is_some_and(|exchange| exchange.start_time > first_start_time)
|
||||||
|
})
|
||||||
|
.unwrap_or(refs.len());
|
||||||
|
let task_id = task.id().clone();
|
||||||
|
let task_refs = task
|
||||||
|
.exchanges()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(exchange_index, _)| ExchangeRef {
|
||||||
|
task_id: task_id.clone(),
|
||||||
|
exchange_index,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
refs.splice(insertion_index..insertion_index, task_refs);
|
||||||
|
}
|
||||||
refs
|
refs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,46 @@ fn test_insert_subtask() {
|
|||||||
assert!(store.contains(&subtask_id));
|
assert!(store.contains(&subtask_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unlinked_direct_cli_task_is_linearized_chronologically() {
|
||||||
|
let base_time = Local::now();
|
||||||
|
let mut root_task = Task::new_optimistic_root();
|
||||||
|
let root_task_id = root_task.id().clone();
|
||||||
|
|
||||||
|
let mut before_cli = create_test_exchange();
|
||||||
|
before_cli.start_time = base_time;
|
||||||
|
let before_cli_id = before_cli.id;
|
||||||
|
root_task.append_exchange(before_cli);
|
||||||
|
|
||||||
|
let mut after_cli = create_test_exchange();
|
||||||
|
after_cli.start_time = base_time + chrono::Duration::seconds(2);
|
||||||
|
let after_cli_id = after_cli.id;
|
||||||
|
root_task.append_exchange(after_cli);
|
||||||
|
|
||||||
|
let mut cli_task =
|
||||||
|
Task::new_optimistic_cli_agent_subtask(BlockId::new(), Some(root_task_id.to_string()));
|
||||||
|
let mut cli_exchange = create_test_exchange();
|
||||||
|
cli_exchange.start_time = base_time + chrono::Duration::seconds(1);
|
||||||
|
let cli_exchange_id = cli_exchange.id;
|
||||||
|
cli_task.append_exchange(cli_exchange);
|
||||||
|
|
||||||
|
let mut store = TaskStore::with_root_task(root_task);
|
||||||
|
store.insert(cli_task);
|
||||||
|
|
||||||
|
let exchange_ids = store
|
||||||
|
.all_exchanges()
|
||||||
|
.map(|exchange| exchange.id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
exchange_ids,
|
||||||
|
vec![before_cli_id, cli_exchange_id, after_cli_id]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.latest_exchange().map(|exchange| exchange.id),
|
||||||
|
Some(after_cli_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_remove_task() {
|
fn test_remove_task() {
|
||||||
let task = create_test_task_with_exchanges(3);
|
let task = create_test_task_with_exchanges(3);
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ use galaxy_core::ui::color::blend::Blend;
|
|||||||
use galaxy_core::ui::theme::color::internal_colors;
|
use galaxy_core::ui::theme::color::internal_colors;
|
||||||
use galaxyui::elements::{
|
use galaxyui::elements::{
|
||||||
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss,
|
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss,
|
||||||
DropShadow, Element, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
DropShadow, Element, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
|
||||||
ParentElement, Radius, Shrinkable, Text,
|
MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||||
};
|
};
|
||||||
use galaxyui::fonts::{Properties, Weight};
|
use galaxyui::fonts::{Properties, Weight};
|
||||||
use galaxyui::keymap::{FixedBinding, Keystroke};
|
use galaxyui::keymap::{FixedBinding, Keystroke};
|
||||||
@@ -22,7 +22,7 @@ use crate::appearance::Appearance;
|
|||||||
use crate::ui_components::icons::Icon;
|
use crate::ui_components::icons::Icon;
|
||||||
|
|
||||||
// Modal dimensions based on Figma design.
|
// Modal dimensions based on Figma design.
|
||||||
const MODAL_WIDTH: f32 = 440.;
|
const MODAL_WIDTH: f32 = 680.;
|
||||||
const DIALOG_CORNER_RADIUS: f32 = 8.;
|
const DIALOG_CORNER_RADIUS: f32 = 8.;
|
||||||
|
|
||||||
const HEADER_PADDING_TOP: f32 = 24.;
|
const HEADER_PADDING_TOP: f32 = 24.;
|
||||||
@@ -40,6 +40,7 @@ const OPTIONS_VERTICAL_GAP: f32 = 8.;
|
|||||||
|
|
||||||
const AVATAR_SIZE: f32 = 48.;
|
const AVATAR_SIZE: f32 = 48.;
|
||||||
const AVATAR_ICON_SIZE: f32 = 24.;
|
const AVATAR_ICON_SIZE: f32 = 24.;
|
||||||
|
const OPTION_HEIGHT: f32 = 136.;
|
||||||
|
|
||||||
const TITLE_FONT_SIZE: f32 = 16.;
|
const TITLE_FONT_SIZE: f32 = 16.;
|
||||||
const OPTION_TITLE_FONT_SIZE: f32 = 14.;
|
const OPTION_TITLE_FONT_SIZE: f32 = 14.;
|
||||||
@@ -292,21 +293,25 @@ impl AgentTypeSelector {
|
|||||||
)
|
)
|
||||||
.finish();
|
.finish();
|
||||||
|
|
||||||
Container::new(
|
ConstrainedBox::new(
|
||||||
Flex::row()
|
Container::new(
|
||||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
Flex::row()
|
||||||
.with_spacing(OPTION_GAP)
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||||
.with_child(avatar)
|
.with_spacing(OPTION_GAP)
|
||||||
.with_child(Shrinkable::new(1., text_content).finish())
|
.with_child(avatar)
|
||||||
.finish(),
|
.with_child(Shrinkable::new(1., text_content).finish())
|
||||||
|
.finish(),
|
||||||
|
)
|
||||||
|
.with_padding_left(OPTION_PADDING_HORIZONTAL)
|
||||||
|
.with_padding_right(OPTION_PADDING_HORIZONTAL)
|
||||||
|
.with_padding_top(OPTION_PADDING_VERTICAL)
|
||||||
|
.with_padding_bottom(OPTION_PADDING_VERTICAL)
|
||||||
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(OPTION_CORNER_RADIUS)))
|
||||||
|
.with_border(Border::all(1.).with_border_color(border_color))
|
||||||
|
.with_background(background)
|
||||||
|
.finish(),
|
||||||
)
|
)
|
||||||
.with_padding_left(OPTION_PADDING_HORIZONTAL)
|
.with_height(OPTION_HEIGHT)
|
||||||
.with_padding_right(OPTION_PADDING_HORIZONTAL)
|
|
||||||
.with_padding_top(OPTION_PADDING_VERTICAL)
|
|
||||||
.with_padding_bottom(OPTION_PADDING_VERTICAL)
|
|
||||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(OPTION_CORNER_RADIUS)))
|
|
||||||
.with_border(Border::all(1.).with_border_color(border_color))
|
|
||||||
.with_background(background)
|
|
||||||
.finish()
|
.finish()
|
||||||
})
|
})
|
||||||
.with_cursor(Cursor::PointingHand)
|
.with_cursor(Cursor::PointingHand)
|
||||||
@@ -353,11 +358,11 @@ impl AgentTypeSelector {
|
|||||||
appearance,
|
appearance,
|
||||||
);
|
);
|
||||||
|
|
||||||
let options = Flex::column()
|
let options = Flex::row()
|
||||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||||
.with_spacing(OPTIONS_VERTICAL_GAP)
|
.with_spacing(OPTIONS_VERTICAL_GAP)
|
||||||
.with_child(cloud_agent_option)
|
.with_child(Expanded::new(1., cloud_agent_option).finish())
|
||||||
.with_child(local_agent_option)
|
.with_child(Expanded::new(1., local_agent_option).finish())
|
||||||
.finish();
|
.finish();
|
||||||
|
|
||||||
let body = Container::new(options)
|
let body = Container::new(options)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub struct ActionButtonsConfig {
|
|||||||
pub view_details_item_id: Option<AgentConversationEntryId>,
|
pub view_details_item_id: Option<AgentConversationEntryId>,
|
||||||
/// Conversation link URL (either to the transcript or live session) for copy link button.
|
/// Conversation link URL (either to the transcript or live session) for copy link button.
|
||||||
pub copy_link_url: Option<String>,
|
pub copy_link_url: Option<String>,
|
||||||
|
pub delete_conversation_id: Option<AIConversationId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActionButtonsConfig {
|
impl ActionButtonsConfig {
|
||||||
@@ -36,6 +37,7 @@ impl ActionButtonsConfig {
|
|||||||
&& self.fork_conversation_id.is_none()
|
&& self.fork_conversation_id.is_none()
|
||||||
&& self.view_details_item_id.is_none()
|
&& self.view_details_item_id.is_none()
|
||||||
&& self.copy_link_url.is_none()
|
&& self.copy_link_url.is_none()
|
||||||
|
&& self.delete_conversation_id.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create config for a task.
|
/// Create config for a task.
|
||||||
@@ -58,6 +60,7 @@ impl ActionButtonsConfig {
|
|||||||
fork_conversation_id: None,
|
fork_conversation_id: None,
|
||||||
view_details_item_id: None,
|
view_details_item_id: None,
|
||||||
copy_link_url,
|
copy_link_url,
|
||||||
|
delete_conversation_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +78,7 @@ impl ActionButtonsConfig {
|
|||||||
fork_conversation_id: Some(conversation_id),
|
fork_conversation_id: Some(conversation_id),
|
||||||
view_details_item_id: None,
|
view_details_item_id: None,
|
||||||
copy_link_url,
|
copy_link_url,
|
||||||
|
delete_conversation_id: Some(conversation_id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,6 +91,7 @@ pub enum AgentDetailsButtonEvent {
|
|||||||
ForkConversation { conversation_id: AIConversationId },
|
ForkConversation { conversation_id: AIConversationId },
|
||||||
ViewDetails { item_id: AgentConversationEntryId },
|
ViewDetails { item_id: AgentConversationEntryId },
|
||||||
CopyLink { link: String },
|
CopyLink { link: String },
|
||||||
|
DeleteConversation { conversation_id: AIConversationId },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Actions dispatched by button clicks (internal).
|
/// Actions dispatched by button clicks (internal).
|
||||||
@@ -97,6 +102,7 @@ pub enum AgentDetailsAction {
|
|||||||
ForkConversation,
|
ForkConversation,
|
||||||
ViewDetails,
|
ViewDetails,
|
||||||
CopyLink,
|
CopyLink,
|
||||||
|
DeleteConversation,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reusable action buttons row for details panel.
|
/// Reusable action buttons row for details panel.
|
||||||
@@ -107,6 +113,7 @@ pub struct ConversationActionButtonsRow {
|
|||||||
fork_conversation_button: ViewHandle<ActionButton>,
|
fork_conversation_button: ViewHandle<ActionButton>,
|
||||||
view_details_button: ViewHandle<ActionButton>,
|
view_details_button: ViewHandle<ActionButton>,
|
||||||
copy_link_button: ViewHandle<ActionButton>,
|
copy_link_button: ViewHandle<ActionButton>,
|
||||||
|
delete_conversation_button: ViewHandle<ActionButton>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConversationActionButtonsRow {
|
impl ConversationActionButtonsRow {
|
||||||
@@ -156,6 +163,15 @@ impl ConversationActionButtonsRow {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let delete_conversation_button = ctx.add_typed_action_view(|_| {
|
||||||
|
Self::make_action_button(
|
||||||
|
Icon::Trash,
|
||||||
|
"Delete conversation",
|
||||||
|
Some(AnsiColorIdentifier::Red),
|
||||||
|
AgentDetailsAction::DeleteConversation,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config: ActionButtonsConfig::default(),
|
config: ActionButtonsConfig::default(),
|
||||||
open_button,
|
open_button,
|
||||||
@@ -163,6 +179,7 @@ impl ConversationActionButtonsRow {
|
|||||||
fork_conversation_button,
|
fork_conversation_button,
|
||||||
view_details_button,
|
view_details_button,
|
||||||
copy_link_button,
|
copy_link_button,
|
||||||
|
delete_conversation_button,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +248,9 @@ impl View for ConversationActionButtonsRow {
|
|||||||
if self.config.view_details_item_id.is_some() {
|
if self.config.view_details_item_id.is_some() {
|
||||||
row.add_child(ChildView::new(&self.view_details_button).finish());
|
row.add_child(ChildView::new(&self.view_details_button).finish());
|
||||||
}
|
}
|
||||||
|
if self.config.delete_conversation_id.is_some() && !cfg!(target_family = "wasm") {
|
||||||
|
row.add_child(ChildView::new(&self.delete_conversation_button).finish());
|
||||||
|
}
|
||||||
|
|
||||||
row.finish()
|
row.finish()
|
||||||
}
|
}
|
||||||
@@ -280,6 +300,11 @@ impl TypedActionView for ConversationActionButtonsRow {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AgentDetailsAction::DeleteConversation => {
|
||||||
|
if let Some(conversation_id) = self.config.delete_conversation_id {
|
||||||
|
ctx.emit(AgentDetailsButtonEvent::DeleteConversation { conversation_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1078,7 +1078,7 @@ impl AgentManagementView {
|
|||||||
open_action: Option<WorkspaceAction>,
|
open_action: Option<WorkspaceAction>,
|
||||||
copy_link_url: Option<String>,
|
copy_link_url: Option<String>,
|
||||||
) -> ActionButtonsConfig {
|
) -> ActionButtonsConfig {
|
||||||
if let Some(task_id) = entry.identity.ambient_agent_task_id {
|
let mut config = if let Some(task_id) = entry.identity.ambient_agent_task_id {
|
||||||
ActionButtonsConfig::for_task(
|
ActionButtonsConfig::for_task(
|
||||||
task_id,
|
task_id,
|
||||||
&entry.display.status,
|
&entry.display.status,
|
||||||
@@ -1093,7 +1093,15 @@ impl AgentManagementView {
|
|||||||
copy_link_url,
|
copy_link_url,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !entry.capabilities.can_delete
|
||||||
|
|| !entry.display.status.to_conversation_status().is_done()
|
||||||
|
{
|
||||||
|
config.delete_conversation_id = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_action_buttons_event(
|
fn handle_action_buttons_event(
|
||||||
@@ -1173,6 +1181,18 @@ impl AgentManagementView {
|
|||||||
ctx.clipboard()
|
ctx.clipboard()
|
||||||
.write(ClipboardContent::plain_text(link.clone()));
|
.write(ClipboardContent::plain_text(link.clone()));
|
||||||
}
|
}
|
||||||
|
AgentDetailsButtonEvent::DeleteConversation { conversation_id } => {
|
||||||
|
let model = AgentConversationsModel::as_ref(ctx);
|
||||||
|
let conversation_title = model
|
||||||
|
.get_entry_by_id(item_id, ctx)
|
||||||
|
.map(|entry| entry.display.title)
|
||||||
|
.unwrap_or_else(|| "Conversation".to_string());
|
||||||
|
ctx.emit(AgentManagementViewEvent::ShowDeleteConfirmationDialog {
|
||||||
|
conversation_id: *conversation_id,
|
||||||
|
conversation_title,
|
||||||
|
terminal_view_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1395,6 +1415,16 @@ impl AgentManagementView {
|
|||||||
notebook_uid: *notebook_uid,
|
notebook_uid: *notebook_uid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
ConversationDetailsPanelEvent::ShowDeleteConfirmationDialog {
|
||||||
|
conversation_id,
|
||||||
|
conversation_title,
|
||||||
|
} => {
|
||||||
|
ctx.emit(AgentManagementViewEvent::ShowDeleteConfirmationDialog {
|
||||||
|
conversation_id: *conversation_id,
|
||||||
|
conversation_title: conversation_title.clone(),
|
||||||
|
terminal_view_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2232,7 +2262,14 @@ pub enum AgentManagementViewAction {
|
|||||||
|
|
||||||
pub enum AgentManagementViewEvent {
|
pub enum AgentManagementViewEvent {
|
||||||
OpenNewTabAndRunWorkflow(Box<WorkflowType>),
|
OpenNewTabAndRunWorkflow(Box<WorkflowType>),
|
||||||
OpenPlanNotebook { notebook_uid: NotebookId },
|
OpenPlanNotebook {
|
||||||
|
notebook_uid: NotebookId,
|
||||||
|
},
|
||||||
|
ShowDeleteConfirmationDialog {
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
conversation_title: String,
|
||||||
|
terminal_view_id: Option<galaxyui::EntityId>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TypedActionView for AgentManagementView {
|
impl TypedActionView for AgentManagementView {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub mod text {
|
|||||||
pub fn format_input<W: Write>(input: &AIAgentInput, w: &mut W) -> io::Result<()> {
|
pub fn format_input<W: Write>(input: &AIAgentInput, w: &mut W) -> io::Result<()> {
|
||||||
match input {
|
match input {
|
||||||
AIAgentInput::UserQuery { .. }
|
AIAgentInput::UserQuery { .. }
|
||||||
|
| AIAgentInput::CommandCompletionAssessment { .. }
|
||||||
| AIAgentInput::AutoCodeDiffQuery { .. }
|
| AIAgentInput::AutoCodeDiffQuery { .. }
|
||||||
| AIAgentInput::CreateNewProject { .. }
|
| AIAgentInput::CreateNewProject { .. }
|
||||||
| AIAgentInput::CloneRepository { .. }
|
| AIAgentInput::CloneRepository { .. }
|
||||||
@@ -60,6 +61,9 @@ pub mod text {
|
|||||||
RequestCommandOutputResult::CancelledBeforeExecution => {
|
RequestCommandOutputResult::CancelledBeforeExecution => {
|
||||||
writeln!(w, "{CANCELLED_MESSAGE}")
|
writeln!(w, "{CANCELLED_MESSAGE}")
|
||||||
}
|
}
|
||||||
|
RequestCommandOutputResult::ExecutionError { command, message } => {
|
||||||
|
writeln!(w, "Command `{command}` was not executed: {message}")
|
||||||
|
}
|
||||||
RequestCommandOutputResult::Denylisted { .. } => {
|
RequestCommandOutputResult::Denylisted { .. } => {
|
||||||
writeln!(
|
writeln!(
|
||||||
w,
|
w,
|
||||||
@@ -432,6 +436,12 @@ pub mod text {
|
|||||||
AIAgentActionType::RunAgents(_) => (),
|
AIAgentActionType::RunAgents(_) => (),
|
||||||
AIAgentActionType::WaitForEvents { .. } => (),
|
AIAgentActionType::WaitForEvents { .. } => (),
|
||||||
},
|
},
|
||||||
|
AIAgentOutputMessageType::RuntimeActivity(activity) => {
|
||||||
|
writeln!(w, "{}", activity.title)?;
|
||||||
|
if let Some(output) = &activity.output {
|
||||||
|
writeln!(w, "{output}")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
AIAgentOutputMessageType::TodoOperation(operation) => match operation {
|
AIAgentOutputMessageType::TodoOperation(operation) => match operation {
|
||||||
TodoOperation::UpdateTodos { todos } => {
|
TodoOperation::UpdateTodos { todos } => {
|
||||||
writeln!(w, "Updated TODO list:")?;
|
writeln!(w, "Updated TODO list:")?;
|
||||||
@@ -779,6 +789,7 @@ pub mod json {
|
|||||||
match input {
|
match input {
|
||||||
// Do not include the user query, since it's already provided as input to the agent.
|
// Do not include the user query, since it's already provided as input to the agent.
|
||||||
AIAgentInput::UserQuery { .. }
|
AIAgentInput::UserQuery { .. }
|
||||||
|
| AIAgentInput::CommandCompletionAssessment { .. }
|
||||||
| AIAgentInput::AutoCodeDiffQuery { .. }
|
| AIAgentInput::AutoCodeDiffQuery { .. }
|
||||||
| AIAgentInput::CreateNewProject { .. }
|
| AIAgentInput::CreateNewProject { .. }
|
||||||
| AIAgentInput::CloneRepository { .. }
|
| AIAgentInput::CloneRepository { .. }
|
||||||
@@ -821,6 +832,11 @@ pub mod json {
|
|||||||
RequestCommandOutputResult::CancelledBeforeExecution => {
|
RequestCommandOutputResult::CancelledBeforeExecution => {
|
||||||
Some(JsonMessage::ToolCanceled)
|
Some(JsonMessage::ToolCanceled)
|
||||||
}
|
}
|
||||||
|
RequestCommandOutputResult::ExecutionError { message, .. } => {
|
||||||
|
Some(JsonMessage::ToolError {
|
||||||
|
error: Cow::Borrowed(message),
|
||||||
|
})
|
||||||
|
}
|
||||||
RequestCommandOutputResult::Denylisted { .. } => Some(JsonMessage::ToolError {
|
RequestCommandOutputResult::Denylisted { .. } => Some(JsonMessage::ToolError {
|
||||||
error: Cow::Borrowed(
|
error: Cow::Borrowed(
|
||||||
"Command was not allowed to run due to presence on denylist",
|
"Command was not allowed to run due to presence on denylist",
|
||||||
@@ -1144,7 +1160,8 @@ pub mod json {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
AIAgentOutputMessageType::MessagesReceivedFromAgents { .. }
|
AIAgentOutputMessageType::MessagesReceivedFromAgents { .. }
|
||||||
| AIAgentOutputMessageType::EventsFromAgents { .. } => None,
|
| AIAgentOutputMessageType::EventsFromAgents { .. }
|
||||||
|
| AIAgentOutputMessageType::RuntimeActivity(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ use aws_config::BehaviorVersion;
|
|||||||
use aws_credential_types::provider::ProvideCredentials;
|
use aws_credential_types::provider::ProvideCredentials;
|
||||||
use aws_sdk_bedrockruntime::config::Region;
|
use aws_sdk_bedrockruntime::config::Region;
|
||||||
use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
|
use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
|
||||||
|
use galaxy_agent_core::AgentError;
|
||||||
|
|
||||||
use super::convert::{build_converse_request, CachingConfig, ConversationMessage, ToolDefinition};
|
use super::convert::{build_converse_request, CachingConfig, ConversationMessage, ToolDefinition};
|
||||||
use super::diagnostic::BedrockDiagnosticLogger;
|
use super::diagnostic::BedrockDiagnosticLogger;
|
||||||
use super::external_config::ExternalBedrockConfig;
|
use super::external_config::ExternalBedrockConfig;
|
||||||
use super::models::apply_cross_region_prefix;
|
use super::models::apply_cross_region_prefix;
|
||||||
use super::response_translator::bedrock_stream_to_response_events;
|
use super::response_translator::bedrock_stream_to_response_events;
|
||||||
use crate::ai::agent::api::ResponseStream;
|
use super::runtime::BedrockAgentRuntime;
|
||||||
|
use crate::ai::agent::api::LegacyResponseStream;
|
||||||
use crate::settings::ai::BedrockAuthMethod;
|
use crate::settings::ai::BedrockAuthMethod;
|
||||||
|
|
||||||
fn strip_context_marker(model_id: &str) -> String {
|
fn strip_context_marker(model_id: &str) -> String {
|
||||||
@@ -38,6 +40,7 @@ pub struct BedrockClientConfig {
|
|||||||
pub secret_access_key: String,
|
pub secret_access_key: String,
|
||||||
pub session_token: Option<String>,
|
pub session_token: Option<String>,
|
||||||
pub cross_region_inference: bool,
|
pub cross_region_inference: bool,
|
||||||
|
pub use_rig: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BedrockClientConfig {
|
impl BedrockClientConfig {
|
||||||
@@ -141,8 +144,8 @@ impl BedrockClient {
|
|||||||
match provider.provide_credentials().await {
|
match provider.provide_credentials().await {
|
||||||
Ok(creds) => {
|
Ok(creds) => {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[bedrock] Resolved AWS credentials successfully: access_key_id={:?}, has_session_token={}, expiry={:?}",
|
"[bedrock] Resolved AWS credentials successfully: has_access_key_id={}, has_session_token={}, expiry={:?}",
|
||||||
creds.access_key_id(),
|
!creds.access_key_id().is_empty(),
|
||||||
creds.session_token().is_some(),
|
creds.session_token().is_some(),
|
||||||
creds.expiry(),
|
creds.expiry(),
|
||||||
);
|
);
|
||||||
@@ -168,6 +171,23 @@ impl BedrockClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn agent_runtime(
|
||||||
|
&self,
|
||||||
|
model: String,
|
||||||
|
cross_region_inference: bool,
|
||||||
|
max_output_tokens: Option<u64>,
|
||||||
|
caching_config: CachingConfig,
|
||||||
|
) -> Result<BedrockAgentRuntime, AgentError> {
|
||||||
|
BedrockAgentRuntime::new(
|
||||||
|
self.runtime_client.clone(),
|
||||||
|
model,
|
||||||
|
self.region.clone(),
|
||||||
|
cross_region_inference,
|
||||||
|
max_output_tokens,
|
||||||
|
caching_config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn converse_stream(
|
pub async fn converse_stream(
|
||||||
&self,
|
&self,
|
||||||
@@ -185,7 +205,7 @@ impl BedrockClient {
|
|||||||
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
|
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
|
||||||
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
|
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
|
||||||
tool_result_archive: Vec<ConversationMessage>,
|
tool_result_archive: Vec<ConversationMessage>,
|
||||||
) -> Result<ResponseStream, BedrockError> {
|
) -> Result<LegacyResponseStream, BedrockError> {
|
||||||
let base_model_id = strip_context_marker(model_id);
|
let base_model_id = strip_context_marker(model_id);
|
||||||
let effective_model_id = if cross_region_inference {
|
let effective_model_id = if cross_region_inference {
|
||||||
apply_cross_region_prefix(&base_model_id, &self.region)
|
apply_cross_region_prefix(&base_model_id, &self.region)
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ use std::collections::HashMap;
|
|||||||
use aws_sdk_bedrockruntime::types::{
|
use aws_sdk_bedrockruntime::types::{
|
||||||
CachePointBlock, CachePointType, CacheTtl, ContentBlock, ConversationRole, ImageBlock,
|
CachePointBlock, CachePointType, CacheTtl, ContentBlock, ConversationRole, ImageBlock,
|
||||||
ImageFormat, ImageSource, InferenceConfiguration, Message as BedrockMessage,
|
ImageFormat, ImageSource, InferenceConfiguration, Message as BedrockMessage,
|
||||||
SystemContentBlock, Tool, ToolConfiguration, ToolInputSchema, ToolResultBlock,
|
ReasoningContentBlock, ReasoningTextBlock, SystemContentBlock, Tool, ToolConfiguration,
|
||||||
ToolResultContentBlock, ToolResultStatus, ToolSpecification, ToolUseBlock,
|
ToolInputSchema, ToolResultBlock, ToolResultContentBlock, ToolResultStatus, ToolSpecification,
|
||||||
|
ToolUseBlock,
|
||||||
};
|
};
|
||||||
use aws_smithy_types::{Blob, Document};
|
use aws_smithy_types::{Blob, Document};
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
@@ -148,6 +149,15 @@ fn convert_messages(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|part| match part {
|
.map(|part| match part {
|
||||||
ContentPart::Text(text) => ContentBlock::Text(text),
|
ContentPart::Text(text) => ContentBlock::Text(text),
|
||||||
|
ContentPart::Reasoning { text, signature } => {
|
||||||
|
ContentBlock::ReasoningContent(ReasoningContentBlock::ReasoningText(
|
||||||
|
ReasoningTextBlock::builder()
|
||||||
|
.text(text)
|
||||||
|
.set_signature(signature)
|
||||||
|
.build()
|
||||||
|
.expect("valid reasoning text block"),
|
||||||
|
))
|
||||||
|
}
|
||||||
ContentPart::Image { data, mime_type } => image_content_block(data, &mime_type),
|
ContentPart::Image { data, mime_type } => image_content_block(data, &mime_type),
|
||||||
ContentPart::ToolUse {
|
ContentPart::ToolUse {
|
||||||
tool_use_id,
|
tool_use_id,
|
||||||
|
|||||||
@@ -258,7 +258,10 @@ fn test_system_prompt_separated_from_messages() {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
CachingConfig::default(),
|
CachingConfig {
|
||||||
|
enabled: false,
|
||||||
|
extended_ttl_requested: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(result.system.len(), 1);
|
assert_eq!(result.system.len(), 1);
|
||||||
@@ -334,7 +337,10 @@ fn test_tool_definitions_produce_tool_config() {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
CachingConfig::default(),
|
CachingConfig {
|
||||||
|
enabled: false,
|
||||||
|
extended_ttl_requested: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(result.tool_config.is_some());
|
assert!(result.tool_config.is_some());
|
||||||
|
|||||||
@@ -492,6 +492,14 @@ fn serialize_messages(messages: &[ConversationMessage]) -> JsonValue {
|
|||||||
super::convert::ContentPart::Text(t) => {
|
super::convert::ContentPart::Text(t) => {
|
||||||
serde_json::json!({"type": "text", "text": t})
|
serde_json::json!({"type": "text", "text": t})
|
||||||
}
|
}
|
||||||
|
super::convert::ContentPart::Reasoning { text, signature } => {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "reasoning",
|
||||||
|
"char_length": text.len(),
|
||||||
|
"has_signature": signature.is_some(),
|
||||||
|
"text": "REDACTED",
|
||||||
|
})
|
||||||
|
}
|
||||||
super::convert::ContentPart::Image { data, mime_type } => {
|
super::convert::ContentPart::Image { data, mime_type } => {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"type": "image",
|
"type": "image",
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! AWS Bedrock control-plane discovery.
|
||||||
|
//!
|
||||||
|
//! The foundation-model catalog is only a candidate list. Every candidate is
|
||||||
|
//! checked with `GetFoundationModelAvailability` before it is offered to the
|
||||||
|
//! user or persisted in Galaxy settings.
|
||||||
|
|
||||||
|
use aws_config::BehaviorVersion;
|
||||||
|
use aws_sdk_bedrock::Client;
|
||||||
|
use aws_sdk_bedrockruntime::config::Region;
|
||||||
|
|
||||||
|
use super::client::{BedrockClientConfig, BedrockError};
|
||||||
|
use crate::settings::ai::BedrockModelConfig;
|
||||||
|
|
||||||
|
pub async fn discover_available_models(
|
||||||
|
config: BedrockClientConfig,
|
||||||
|
) -> Result<Vec<BedrockModelConfig>, String> {
|
||||||
|
let aws_config = load_aws_config(&config)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let client = Client::new(&aws_config);
|
||||||
|
let catalog = client
|
||||||
|
.list_foundation_models()
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Could not list AWS Bedrock foundation models: {error}"))?;
|
||||||
|
|
||||||
|
let mut models = Vec::new();
|
||||||
|
for summary in catalog.model_summaries() {
|
||||||
|
let model_id = summary.model_id();
|
||||||
|
let availability = match client
|
||||||
|
.get_foundation_model_availability()
|
||||||
|
.model_id(model_id)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(availability) => availability,
|
||||||
|
Err(error) => {
|
||||||
|
log::debug!(
|
||||||
|
"[bedrock] Availability check failed for {model_id}; excluding model: {error}"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !model_availability_is_usable(
|
||||||
|
availability
|
||||||
|
.agreement_availability()
|
||||||
|
.map(|agreement| agreement.status().as_str()),
|
||||||
|
availability.authorization_status().as_str(),
|
||||||
|
availability.entitlement_availability().as_str(),
|
||||||
|
availability.region_availability().as_str(),
|
||||||
|
) {
|
||||||
|
log::debug!(
|
||||||
|
"[bedrock] Excluding {model_id}: agreement={}, authorization={}, entitlement={}, region={}",
|
||||||
|
availability
|
||||||
|
.agreement_availability()
|
||||||
|
.map(|agreement| agreement.status().as_str())
|
||||||
|
.unwrap_or("MISSING"),
|
||||||
|
availability.authorization_status().as_str(),
|
||||||
|
availability.entitlement_availability().as_str(),
|
||||||
|
availability.region_availability().as_str(),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let display_name = summary
|
||||||
|
.model_name()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| prettify_model_id(model_id));
|
||||||
|
let vision_supported = summary
|
||||||
|
.input_modalities()
|
||||||
|
.iter()
|
||||||
|
.any(|modality| modality.as_str() == "IMAGE");
|
||||||
|
|
||||||
|
models.push(BedrockModelConfig {
|
||||||
|
model_id: model_id.to_owned(),
|
||||||
|
display_name,
|
||||||
|
vision_supported,
|
||||||
|
use_rig: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
models.sort_by(|left, right| left.display_name.cmp(&right.display_name));
|
||||||
|
if models.is_empty() {
|
||||||
|
return Err(
|
||||||
|
"AWS returned no Bedrock models that are authorized and available in this region."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(models)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_availability_is_usable(
|
||||||
|
agreement_status: Option<&str>,
|
||||||
|
authorization_status: &str,
|
||||||
|
entitlement_status: &str,
|
||||||
|
region_status: &str,
|
||||||
|
) -> bool {
|
||||||
|
agreement_status == Some("AVAILABLE")
|
||||||
|
&& authorization_status == "AUTHORIZED"
|
||||||
|
&& entitlement_status == "AVAILABLE"
|
||||||
|
&& region_status == "AVAILABLE"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_aws_config(
|
||||||
|
config: &BedrockClientConfig,
|
||||||
|
) -> Result<aws_config::SdkConfig, BedrockError> {
|
||||||
|
let sdk_config = match config.auth_method {
|
||||||
|
crate::settings::ai::BedrockAuthMethod::Profile
|
||||||
|
| crate::settings::ai::BedrockAuthMethod::Sso => {
|
||||||
|
let mut loader = aws_config::defaults(BehaviorVersion::latest());
|
||||||
|
if !config.profile.is_empty() && config.profile != "default" {
|
||||||
|
loader = loader.profile_name(&config.profile);
|
||||||
|
}
|
||||||
|
if !config.region.is_empty() {
|
||||||
|
loader = loader.region(Region::new(config.region.clone()));
|
||||||
|
}
|
||||||
|
loader.load().await
|
||||||
|
}
|
||||||
|
crate::settings::ai::BedrockAuthMethod::StaticKeys => {
|
||||||
|
if config.access_key_id.is_empty() || config.secret_access_key.is_empty() {
|
||||||
|
return Err(BedrockError::CredentialsNotConfigured);
|
||||||
|
}
|
||||||
|
let credentials = aws_credential_types::Credentials::new(
|
||||||
|
&config.access_key_id,
|
||||||
|
&config.secret_access_key,
|
||||||
|
config.session_token.clone(),
|
||||||
|
None,
|
||||||
|
"galaxy-bedrock-discovery",
|
||||||
|
);
|
||||||
|
let mut loader =
|
||||||
|
aws_config::defaults(BehaviorVersion::latest()).credentials_provider(credentials);
|
||||||
|
loader = loader.region(Region::new(if config.region.is_empty() {
|
||||||
|
"us-east-1".to_string()
|
||||||
|
} else {
|
||||||
|
config.region.clone()
|
||||||
|
}));
|
||||||
|
loader.load().await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if sdk_config.region().is_none() {
|
||||||
|
return Err(BedrockError::RegionNotConfigured);
|
||||||
|
}
|
||||||
|
Ok(sdk_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prettify_model_id(model_id: &str) -> String {
|
||||||
|
model_id
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(model_id)
|
||||||
|
.replace(['-', ':'], " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::model_availability_is_usable;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requires_every_availability_status() {
|
||||||
|
assert!(model_availability_is_usable(
|
||||||
|
Some("AVAILABLE"),
|
||||||
|
"AUTHORIZED",
|
||||||
|
"AVAILABLE",
|
||||||
|
"AVAILABLE",
|
||||||
|
));
|
||||||
|
assert!(!model_availability_is_usable(
|
||||||
|
None,
|
||||||
|
"AUTHORIZED",
|
||||||
|
"AVAILABLE",
|
||||||
|
"AVAILABLE",
|
||||||
|
));
|
||||||
|
assert!(!model_availability_is_usable(
|
||||||
|
Some("AVAILABLE"),
|
||||||
|
"NOT_AUTHORIZED",
|
||||||
|
"AVAILABLE",
|
||||||
|
"AVAILABLE",
|
||||||
|
));
|
||||||
|
assert!(!model_availability_is_usable(
|
||||||
|
Some("AVAILABLE"),
|
||||||
|
"AUTHORIZED",
|
||||||
|
"NOT_AVAILABLE",
|
||||||
|
"AVAILABLE",
|
||||||
|
));
|
||||||
|
assert!(!model_availability_is_usable(
|
||||||
|
Some("AVAILABLE"),
|
||||||
|
"AUTHORIZED",
|
||||||
|
"AVAILABLE",
|
||||||
|
"NOT_AVAILABLE",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -269,6 +269,7 @@ fn get_test_config() -> Option<BedrockClientConfig> {
|
|||||||
secret_access_key: String::new(),
|
secret_access_key: String::new(),
|
||||||
session_token: None,
|
session_token: None,
|
||||||
cross_region_inference: false,
|
cross_region_inference: false,
|
||||||
|
use_rig: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ fn parse_claude_code_model_map(
|
|||||||
model_id: arn,
|
model_id: arn,
|
||||||
display_name,
|
display_name,
|
||||||
vision_supported: true,
|
vision_supported: true,
|
||||||
|
use_rig: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ fn get_test_config() -> Option<BedrockClientConfig> {
|
|||||||
secret_access_key: String::new(),
|
secret_access_key: String::new(),
|
||||||
session_token: None,
|
session_token: None,
|
||||||
cross_region_inference: false,
|
cross_region_inference: false,
|
||||||
|
use_rig: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ pub mod client;
|
|||||||
pub mod convert;
|
pub mod convert;
|
||||||
pub mod crash_log;
|
pub mod crash_log;
|
||||||
pub mod diagnostic;
|
pub mod diagnostic;
|
||||||
|
pub mod discovery;
|
||||||
pub mod external_config;
|
pub mod external_config;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod request_translator;
|
pub mod request_translator;
|
||||||
pub mod response_translator;
|
pub mod response_translator;
|
||||||
|
pub mod runtime;
|
||||||
pub mod settings_view;
|
pub mod settings_view;
|
||||||
pub mod translator;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod convert_tests;
|
mod convert_tests;
|
||||||
|
|||||||
+23
-143
@@ -1,152 +1,32 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use super::external_config::ExternalBedrockConfig;
|
|
||||||
use crate::settings::ai::BedrockModelConfig;
|
use crate::settings::ai::BedrockModelConfig;
|
||||||
|
|
||||||
pub struct DefaultModel {
|
pub fn configured_model_uses_rig(
|
||||||
pub model_id: &'static str,
|
selected_model_id: &str,
|
||||||
pub display_name: &'static str,
|
configured_models: &[BedrockModelConfig],
|
||||||
pub vision_supported: bool,
|
region: &str,
|
||||||
pub context_size: u32,
|
cross_region_inference: bool,
|
||||||
|
) -> bool {
|
||||||
|
let selected_model_id = strip_context_marker(selected_model_id);
|
||||||
|
configured_models.iter().any(|model| {
|
||||||
|
if !model.use_rig {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let configured_model_id = strip_context_marker(&model.model_id);
|
||||||
|
if configured_model_id == selected_model_id {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
galaxy_agent_rig::resolve_bedrock_model_id(&model.model_id, region, cross_region_inference)
|
||||||
|
.is_ok_and(|resolved| strip_context_marker(&resolved) == selected_model_id)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const DEFAULT_BEDROCK_MODELS: &[DefaultModel] = &[
|
fn strip_context_marker(model_id: &str) -> &str {
|
||||||
DefaultModel {
|
model_id
|
||||||
model_id: "us.anthropic.claude-opus-4-6-v1[1m]",
|
.strip_suffix("[1m]")
|
||||||
display_name: "Claude Opus 4.6 (1M)",
|
.or_else(|| model_id.strip_suffix("[1M]"))
|
||||||
vision_supported: true,
|
.unwrap_or(model_id)
|
||||||
context_size: 1_000_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-sonnet-4-6[1m]",
|
|
||||||
display_name: "Claude Sonnet 4.6 (1M)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 1_000_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
|
||||||
display_name: "Claude Sonnet 4.5",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-sonnet-4-20250514-v1:0",
|
|
||||||
display_name: "Claude Sonnet 4",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-3-sonnet-20240229-v1:0",
|
|
||||||
display_name: "Claude 3 Sonnet",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "global.anthropic.claude-sonnet-4-6",
|
|
||||||
display_name: "Claude Sonnet 4.6 (Global)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "global.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
|
||||||
display_name: "Claude Sonnet 4.5 (Global)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "global.anthropic.claude-sonnet-4-20250514-v1:0",
|
|
||||||
display_name: "Claude Sonnet 4 (Global)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-opus-4-5-20251101-v1:0",
|
|
||||||
display_name: "Claude Opus 4.5",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-opus-4-1-20250805-v1:0",
|
|
||||||
display_name: "Claude Opus 4.1",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "global.anthropic.claude-opus-4-6-v1",
|
|
||||||
display_name: "Claude Opus 4.6 (Global)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "global.anthropic.claude-opus-4-5-20251101-v1:0",
|
|
||||||
display_name: "Claude Opus 4.5 (Global)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
|
||||||
display_name: "Claude Haiku 4.5",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-3-haiku-20240307-v1:0",
|
|
||||||
display_name: "Claude 3 Haiku",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-3-5-haiku-20241022-v1:0",
|
|
||||||
display_name: "Claude 3.5 Haiku",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
|
||||||
display_name: "Claude Haiku 4.5 (Global)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn get_effective_models(user_models: &[BedrockModelConfig]) -> Vec<BedrockModelConfig> {
|
|
||||||
if !user_models.is_empty() {
|
|
||||||
return user_models.to_vec();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to models from external configs (Claude Code / OpenCode)
|
|
||||||
let external = ExternalBedrockConfig::load();
|
|
||||||
if !external.models.is_empty() {
|
|
||||||
log::info!(
|
|
||||||
"[bedrock] Using {} model(s) from external config",
|
|
||||||
external.models.len()
|
|
||||||
);
|
|
||||||
// Merge external models with defaults so the user still sees all defaults
|
|
||||||
let mut models = external.models;
|
|
||||||
let defaults: Vec<BedrockModelConfig> = DEFAULT_BEDROCK_MODELS
|
|
||||||
.iter()
|
|
||||||
.map(|m| BedrockModelConfig {
|
|
||||||
model_id: m.model_id.to_string(),
|
|
||||||
display_name: m.display_name.to_string(),
|
|
||||||
vision_supported: m.vision_supported,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
for default in defaults {
|
|
||||||
if !models.iter().any(|m| m.model_id == default.model_id) {
|
|
||||||
models.push(default);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return models;
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_BEDROCK_MODELS
|
|
||||||
.iter()
|
|
||||||
.map(|m| BedrockModelConfig {
|
|
||||||
model_id: m.model_id.to_string(),
|
|
||||||
display_name: m.display_name.to_string(),
|
|
||||||
vision_supported: m.vision_supported,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_cross_region_prefix(model_id: &str, region: &str) -> String {
|
pub fn apply_cross_region_prefix(model_id: &str, region: &str) -> String {
|
||||||
|
|||||||
@@ -78,28 +78,37 @@ fn test_cross_region_prefix_unknown_region() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_effective_models_empty_returns_defaults() {
|
|
||||||
let models = get_effective_models(&[]);
|
|
||||||
assert_eq!(models.len(), DEFAULT_BEDROCK_MODELS.len());
|
|
||||||
assert_eq!(models[0].model_id, "anthropic.claude-opus-4-6[1m]");
|
|
||||||
assert_eq!(models[0].display_name, "Claude Opus 4.6");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_effective_models_custom_overrides() {
|
|
||||||
let custom = vec![BedrockModelConfig {
|
|
||||||
model_id: "custom.model-v1:0".to_string(),
|
|
||||||
display_name: "Custom Model".to_string(),
|
|
||||||
vision_supported: false,
|
|
||||||
}];
|
|
||||||
let models = get_effective_models(&custom);
|
|
||||||
assert_eq!(models.len(), 1);
|
|
||||||
assert_eq!(models[0].model_id, "custom.model-v1:0");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cross_region_prefix_skips_arn() {
|
fn test_cross_region_prefix_skips_arn() {
|
||||||
let arn = "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy";
|
let arn = "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy";
|
||||||
assert_eq!(apply_cross_region_prefix(arn, "us-east-1"), arn);
|
assert_eq!(apply_cross_region_prefix(arn, "us-east-1"), arn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rig_opt_in_matches_context_markers_and_resolved_inference_profiles() {
|
||||||
|
let configured = vec![BedrockModelConfig {
|
||||||
|
model_id: "anthropic.claude-test[1m]".to_string(),
|
||||||
|
display_name: "Claude Test".to_string(),
|
||||||
|
vision_supported: false,
|
||||||
|
use_rig: true,
|
||||||
|
}];
|
||||||
|
|
||||||
|
assert!(configured_model_uses_rig(
|
||||||
|
"us.anthropic.claude-test",
|
||||||
|
&configured,
|
||||||
|
"us-east-1",
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert!(configured_model_uses_rig(
|
||||||
|
"anthropic.claude-test[1M]",
|
||||||
|
&configured,
|
||||||
|
"us-east-1",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert!(!configured_model_uses_rig(
|
||||||
|
"anthropic.other-model",
|
||||||
|
&configured,
|
||||||
|
"us-east-1",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use warp_multi_agent_api as api;
|
|||||||
use super::convert::{
|
use super::convert::{
|
||||||
ContentPart, ConversationMessage, MessageContent, MessageRole, ToolDefinition,
|
ContentPart, ConversationMessage, MessageContent, MessageRole, ToolDefinition,
|
||||||
};
|
};
|
||||||
|
use crate::ai::agent::api::mark_internal_command_completion_assessment;
|
||||||
|
|
||||||
/// Command-monitor turns must wake often enough to react to steering and user-specified deadlines.
|
/// Command-monitor turns must wake often enough to react to steering and user-specified deadlines.
|
||||||
///
|
///
|
||||||
@@ -91,32 +92,11 @@ pub fn extract_new_input_messages(request: &api::Request) -> Vec<ConversationMes
|
|||||||
) => {
|
) => {
|
||||||
if let Some(user_query) = &cli_query.user_query {
|
if let Some(user_query) = &cli_query.user_query {
|
||||||
if !user_query.query.is_empty() {
|
if !user_query.query.is_empty() {
|
||||||
let query_text =
|
|
||||||
if let Some(running_cmd) = &cli_query.running_command {
|
|
||||||
let mut context =
|
|
||||||
format!("[Running command: {}]\n", running_cmd.command);
|
|
||||||
if let Some(snapshot) = &running_cmd.snapshot {
|
|
||||||
if !snapshot.command_id.is_empty() {
|
|
||||||
context.push_str(&format!(
|
|
||||||
"[Command ID: {}]\n",
|
|
||||||
snapshot.command_id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !snapshot.output.is_empty() {
|
|
||||||
context.push_str(&format!(
|
|
||||||
"[Terminal output:\n{}\n]\n",
|
|
||||||
snapshot.output
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context.push_str(&user_query.query);
|
|
||||||
context
|
|
||||||
} else {
|
|
||||||
user_query.query.clone()
|
|
||||||
};
|
|
||||||
user_queries.push(ConversationMessage {
|
user_queries.push(ConversationMessage {
|
||||||
role: MessageRole::User,
|
role: MessageRole::User,
|
||||||
content: MessageContent::Text(query_text),
|
content: MessageContent::Text(cli_query_text(
|
||||||
|
cli_query, user_query,
|
||||||
|
)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,24 +545,7 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
|
|||||||
) => {
|
) => {
|
||||||
if let Some(user_query) = &cli_query.user_query {
|
if let Some(user_query) = &cli_query.user_query {
|
||||||
if !user_query.query.is_empty() {
|
if !user_query.query.is_empty() {
|
||||||
let query_text =
|
let mut message = api::Message {
|
||||||
if let Some(running_cmd) = &cli_query.running_command {
|
|
||||||
let mut context =
|
|
||||||
format!("[Running command: {}]\n", running_cmd.command);
|
|
||||||
if let Some(snapshot) = &running_cmd.snapshot {
|
|
||||||
if !snapshot.output.is_empty() {
|
|
||||||
context.push_str(&format!(
|
|
||||||
"[Terminal output:\n{}\n]\n",
|
|
||||||
snapshot.output
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context.push_str(&user_query.query);
|
|
||||||
context
|
|
||||||
} else {
|
|
||||||
user_query.query.clone()
|
|
||||||
};
|
|
||||||
results.push(api::Message {
|
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
task_id: task_id.clone(),
|
task_id: task_id.clone(),
|
||||||
request_id: String::new(),
|
request_id: String::new(),
|
||||||
@@ -592,11 +555,15 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
|
|||||||
fetched_memories: vec![],
|
fetched_memories: vec![],
|
||||||
message: Some(api::message::Message::UserQuery(
|
message: Some(api::message::Message::UserQuery(
|
||||||
api::message::UserQuery {
|
api::message::UserQuery {
|
||||||
query: query_text,
|
query: cli_query_text(cli_query, user_query),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)),
|
)),
|
||||||
});
|
};
|
||||||
|
if cli_query_is_completed_assessment(cli_query) {
|
||||||
|
mark_internal_command_completion_assessment(&mut message);
|
||||||
|
}
|
||||||
|
results.push(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -750,6 +717,7 @@ fn persist_input_images_on_latest_user_message(
|
|||||||
Some(api::input_context::Image { data, mime_type })
|
Some(api::input_context::Image { data, mime_type })
|
||||||
}
|
}
|
||||||
Some(ContentPart::Text(_))
|
Some(ContentPart::Text(_))
|
||||||
|
| Some(ContentPart::Reasoning { .. })
|
||||||
| Some(ContentPart::ToolUse { .. })
|
| Some(ContentPart::ToolUse { .. })
|
||||||
| Some(ContentPart::ToolResult { .. })
|
| Some(ContentPart::ToolResult { .. })
|
||||||
| None => None,
|
| None => None,
|
||||||
@@ -873,11 +841,17 @@ fn is_pure_tool_result(content: &MessageContent) -> bool {
|
|||||||
fn strip_tool_result_parts(content: &mut MessageContent) {
|
fn strip_tool_result_parts(content: &mut MessageContent) {
|
||||||
if let MessageContent::MultiPart(parts) = content {
|
if let MessageContent::MultiPart(parts) = content {
|
||||||
parts.retain(|p| !matches!(p, ContentPart::ToolResult { .. }));
|
parts.retain(|p| !matches!(p, ContentPart::ToolResult { .. }));
|
||||||
if parts.len() == 1 && !matches!(parts.first(), Some(ContentPart::Image { .. })) {
|
if parts.len() == 1
|
||||||
|
&& !matches!(
|
||||||
|
parts.first(),
|
||||||
|
Some(ContentPart::Image { .. } | ContentPart::Reasoning { .. })
|
||||||
|
)
|
||||||
|
{
|
||||||
let part = parts.remove(0);
|
let part = parts.remove(0);
|
||||||
*content = match part {
|
*content = match part {
|
||||||
ContentPart::Text(t) => MessageContent::Text(t),
|
ContentPart::Text(t) => MessageContent::Text(t),
|
||||||
ContentPart::Image { .. } => unreachable!(),
|
ContentPart::Image { .. } => unreachable!(),
|
||||||
|
ContentPart::Reasoning { .. } => unreachable!(),
|
||||||
ContentPart::ToolUse {
|
ContentPart::ToolUse {
|
||||||
tool_use_id,
|
tool_use_id,
|
||||||
name,
|
name,
|
||||||
@@ -916,11 +890,17 @@ fn strip_orphaned_tool_result_parts(
|
|||||||
ContentPart::ToolResult { tool_use_id, .. } => valid_ids.contains(tool_use_id),
|
ContentPart::ToolResult { tool_use_id, .. } => valid_ids.contains(tool_use_id),
|
||||||
_ => true,
|
_ => true,
|
||||||
});
|
});
|
||||||
if parts.len() == 1 && !matches!(parts.first(), Some(ContentPart::Image { .. })) {
|
if parts.len() == 1
|
||||||
|
&& !matches!(
|
||||||
|
parts.first(),
|
||||||
|
Some(ContentPart::Image { .. } | ContentPart::Reasoning { .. })
|
||||||
|
)
|
||||||
|
{
|
||||||
let part = parts.remove(0);
|
let part = parts.remove(0);
|
||||||
*content = match part {
|
*content = match part {
|
||||||
ContentPart::Text(t) => MessageContent::Text(t),
|
ContentPart::Text(t) => MessageContent::Text(t),
|
||||||
ContentPart::Image { .. } => unreachable!(),
|
ContentPart::Image { .. } => unreachable!(),
|
||||||
|
ContentPart::Reasoning { .. } => unreachable!(),
|
||||||
ContentPart::ToolUse {
|
ContentPart::ToolUse {
|
||||||
tool_use_id,
|
tool_use_id,
|
||||||
name,
|
name,
|
||||||
@@ -1222,12 +1202,57 @@ fn collect_tool_result_ids(content: &MessageContent, ids: &mut std::collections:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cli_query_is_completed_assessment(cli_query: &api::request::input::CliAgentUserQuery) -> bool {
|
||||||
|
cli_query
|
||||||
|
.user_query
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|query| query.intended_agent() == api::AgentType::Primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cli_query_text(
|
||||||
|
cli_query: &api::request::input::CliAgentUserQuery,
|
||||||
|
user_query: &api::request::input::UserQuery,
|
||||||
|
) -> String {
|
||||||
|
let Some(command) = &cli_query.running_command else {
|
||||||
|
return user_query.query.clone();
|
||||||
|
};
|
||||||
|
let completed = cli_query_is_completed_assessment(cli_query);
|
||||||
|
let mut context = format!(
|
||||||
|
"[{}: {}]\n",
|
||||||
|
if completed {
|
||||||
|
"Completed command"
|
||||||
|
} else {
|
||||||
|
"Running command"
|
||||||
|
},
|
||||||
|
command.command
|
||||||
|
);
|
||||||
|
if let Some(snapshot) = &command.snapshot {
|
||||||
|
if !snapshot.command_id.is_empty() {
|
||||||
|
context.push_str(&format!("[Command ID: {}]\n", snapshot.command_id));
|
||||||
|
}
|
||||||
|
if !snapshot.output.is_empty() {
|
||||||
|
context.push_str(&format!(
|
||||||
|
"[{}:\n{}\n]\n",
|
||||||
|
if completed {
|
||||||
|
"Final terminal output"
|
||||||
|
} else {
|
||||||
|
"Terminal output"
|
||||||
|
},
|
||||||
|
snapshot.output
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.push_str(&user_query.query);
|
||||||
|
context
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum AgentMode {
|
enum AgentMode {
|
||||||
Normal,
|
Normal,
|
||||||
Plan,
|
Plan,
|
||||||
Orchestrate,
|
Orchestrate,
|
||||||
Cli,
|
Cli,
|
||||||
|
CompletedCommandAssessment,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_agent_mode(request: &api::Request) -> AgentMode {
|
fn request_agent_mode(request: &api::Request) -> AgentMode {
|
||||||
@@ -1239,6 +1264,17 @@ fn request_agent_mode(request: &api::Request) -> AgentMode {
|
|||||||
return AgentMode::Normal;
|
return AgentMode::Normal;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if user_inputs.inputs.iter().any(|user_input| {
|
||||||
|
matches!(
|
||||||
|
&user_input.input,
|
||||||
|
Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
|
||||||
|
cli_query
|
||||||
|
)) if cli_query_is_completed_assessment(cli_query)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
return AgentMode::CompletedCommandAssessment;
|
||||||
|
}
|
||||||
|
|
||||||
let mut mode = AgentMode::Normal;
|
let mut mode = AgentMode::Normal;
|
||||||
for user_input in &user_inputs.inputs {
|
for user_input in &user_inputs.inputs {
|
||||||
match &user_input.input {
|
match &user_input.input {
|
||||||
@@ -1483,16 +1519,31 @@ pub fn extract_system_prompt(
|
|||||||
"This turn concerns a running or just-finished shell command. Act as its dedicated \
|
"This turn concerns a running or just-finished shell command. Act as its dedicated \
|
||||||
monitor while still following the user's steering messages. Use the command ID from \
|
monitor while still following the user's steering messages. Use the command ID from \
|
||||||
the running-command context or tool result for every read/write operation. If the \
|
the running-command context or tool result for every read/write operation. If the \
|
||||||
result says the command finished, report its outcome and stop polling. Otherwise, \
|
result says the command finished, report its outcome and stop polling. If it says the \
|
||||||
poll with `read_shell_command_output` and use short delays. Never choose a poll \
|
command is still running, the next assistant output MUST be a tool call. Use \
|
||||||
interval that crosses a user-specified deadline or stop condition. When an explicit \
|
`read_shell_command_output` with a short delay for normal progress. If the snapshot \
|
||||||
stop condition is met, call `interrupt_shell_command` immediately, then poll briefly \
|
clearly shows an interactive pager or editor, do not keep polling: an alternate screen \
|
||||||
to verify the outcome. Never try to encode Ctrl+C as `C-c`, `^C`, `\\x03`, or \
|
containing `(END)` is `less`, so call `write_to_long_running_shell_command` with input \
|
||||||
`\\u0003` through `write_to_long_running_shell_command`; that tool is only for actual \
|
`q` and mode `raw`; for a clearly identified Vim screen, send input `:q` with mode \
|
||||||
process input. Never start a duplicate command merely to check its state, and never \
|
`line`. Poll briefly after sending quit input to verify the outcome. Call \
|
||||||
report completion while a result says it is still running. If user interaction is \
|
`interrupt_shell_command` immediately when the user's explicit stop condition is met. \
|
||||||
the right next step and the transfer tool is available, transfer control with a \
|
Do not end a still-running monitor turn with prose, a status message, or a request for \
|
||||||
clear reason.\n\n",
|
the user to say continue. Never choose a poll interval that crosses a user-specified \
|
||||||
|
deadline or stop condition. After an interrupt, poll briefly to verify the outcome. \
|
||||||
|
Never try to encode Ctrl+C as `C-c`, `^C`, `\\x03`, or `\\u0003` through \
|
||||||
|
`write_to_long_running_shell_command`; that tool is only for actual process input. \
|
||||||
|
Never start a duplicate command merely to check its state, and never report completion \
|
||||||
|
while a result says it is still running. If user interaction is the right next step and \
|
||||||
|
the transfer tool is available, transfer control with a clear reason.\n\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AgentMode::CompletedCommandAssessment => {
|
||||||
|
prompt.push_str("## Completed Command Assessment\n");
|
||||||
|
prompt.push_str(
|
||||||
|
"The monitored command has finished. Use its command, command ID, final terminal \
|
||||||
|
output, and the assessment instruction in the latest hidden input to provide the \
|
||||||
|
final user-facing outcome. Do not continue polling, request more terminal output, \
|
||||||
|
or call tools.\n\n",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1543,6 +1594,10 @@ pub fn extract_system_prompt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
|
pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
|
||||||
|
if request_agent_mode(request) == AgentMode::CompletedCommandAssessment {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
let mut tools = default_tool_definitions();
|
let mut tools = default_tool_definitions();
|
||||||
let mut seen_names: std::collections::HashSet<String> =
|
let mut seen_names: std::collections::HashSet<String> =
|
||||||
tools.iter().map(|t| t.name.clone()).collect();
|
tools.iter().map(|t| t.name.clone()).collect();
|
||||||
@@ -1604,10 +1659,12 @@ pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
|
|||||||
|
|
||||||
fn supported_tool_types(request: &api::Request) -> Option<HashSet<api::ToolType>> {
|
fn supported_tool_types(request: &api::Request) -> Option<HashSet<api::ToolType>> {
|
||||||
let settings = request.settings.as_ref()?;
|
let settings = request.settings.as_ref()?;
|
||||||
let raw_tools = if request_agent_mode(request) == AgentMode::Cli {
|
let raw_tools = match request_agent_mode(request) {
|
||||||
&settings.supported_cli_agent_tools
|
AgentMode::Cli => &settings.supported_cli_agent_tools,
|
||||||
} else {
|
AgentMode::Normal
|
||||||
&settings.supported_tools
|
| AgentMode::Plan
|
||||||
|
| AgentMode::Orchestrate
|
||||||
|
| AgentMode::CompletedCommandAssessment => &settings.supported_tools,
|
||||||
};
|
};
|
||||||
Some(
|
Some(
|
||||||
raw_tools
|
raw_tools
|
||||||
@@ -1617,7 +1674,7 @@ fn supported_tool_types(request: &api::Request) -> Option<HashSet<api::ToolType>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolType>) -> bool {
|
pub(crate) fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolType>) -> bool {
|
||||||
use api::ToolType;
|
use api::ToolType;
|
||||||
|
|
||||||
let has = |tool| supported.contains(&tool);
|
let has = |tool| supported.contains(&tool);
|
||||||
@@ -1639,7 +1696,7 @@ fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolType>) -> boo
|
|||||||
"read_plan" | "read_notebook" => has(ToolType::ReadDocuments),
|
"read_plan" | "read_notebook" => has(ToolType::ReadDocuments),
|
||||||
"create_plan" | "create_notebook" => has(ToolType::CreateDocuments),
|
"create_plan" | "create_notebook" => has(ToolType::CreateDocuments),
|
||||||
"edit_plan" | "edit_notebook" => has(ToolType::EditDocuments),
|
"edit_plan" | "edit_notebook" => has(ToolType::EditDocuments),
|
||||||
"start_agent" => has(ToolType::Subagent) || has(ToolType::StartAgentV2),
|
"run_agents" | "start_agent" => has(ToolType::Subagent) || has(ToolType::StartAgentV2),
|
||||||
"ask_user_question" => has(ToolType::AskUserQuestion),
|
"ask_user_question" => has(ToolType::AskUserQuestion),
|
||||||
"read_skill" => has(ToolType::ReadSkill),
|
"read_skill" => has(ToolType::ReadSkill),
|
||||||
"fetch_conversation" => has(ToolType::FetchConversation),
|
"fetch_conversation" => has(ToolType::FetchConversation),
|
||||||
@@ -1841,7 +1898,7 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
|
|||||||
},
|
},
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "create_plan".to_string(),
|
name: "create_plan".to_string(),
|
||||||
description: "Create a new plan document in Galaxy Drive's Plans folder. Plans are rich-text documents for tracking tasks, architecture decisions, and project notes.".to_string(),
|
description: "Create a new plan document in Galaxy Drive's Plans folder. When the user asks to create a plan for review, use this tool after completing the necessary research instead of only returning plan prose. Plans are rich-text documents for tracking tasks, architecture decisions, and project notes.".to_string(),
|
||||||
input_schema: serde_json::json!({
|
input_schema: serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1894,6 +1951,68 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
|
|||||||
"required": ["diffs"]
|
"required": ["diffs"]
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
ToolDefinition {
|
||||||
|
name: "run_agents".to_string(),
|
||||||
|
description: "Start one or more child agents that share run-wide configuration. Use independent, uniquely named tasks and do not launch duplicate agents for follow-up work. Omitted run-wide fields use the embedded local child runtime and inherit the parent model. After launch, call wait_for_events when you need child-agent results instead of repeating their work yourself.".to_string(),
|
||||||
|
input_schema: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"summary": { "type": "string", "description": "Brief explanation of why child agents help with this task" },
|
||||||
|
"base_prompt": { "type": "string", "default": "", "description": "Instructions prepended to every child prompt" },
|
||||||
|
"skills": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"skill": { "type": "string" },
|
||||||
|
"reference_type": { "type": "string", "enum": ["path", "bundled"] }
|
||||||
|
},
|
||||||
|
"required": ["skill", "reference_type"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_id": { "type": "string", "default": "", "description": "Optional child model override; empty inherits the parent model" },
|
||||||
|
"harness_type": { "type": "string", "default": "", "description": "Optional harness identifier; empty selects the embedded local child runtime" },
|
||||||
|
"execution_mode": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": { "type": "string", "enum": ["local", "remote"], "default": "local" },
|
||||||
|
"environment_id": { "type": "string", "default": "" },
|
||||||
|
"worker_host": { "type": "string", "default": "" },
|
||||||
|
"computer_use_enabled": { "type": "boolean", "default": false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent_run_configs": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string", "description": "Unique child name" },
|
||||||
|
"prompt": { "type": "string", "default": "", "description": "Child-specific instructions" },
|
||||||
|
"title": { "type": "string", "default": "", "description": "Optional display title" }
|
||||||
|
},
|
||||||
|
"required": ["name", "prompt"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plan_id": { "type": "string", "default": "", "description": "Optional associated plan document ID" }
|
||||||
|
},
|
||||||
|
"required": ["summary", "agent_run_configs"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ToolDefinition {
|
||||||
|
name: "wait_for_events".to_string(),
|
||||||
|
description: "Yield after starting child agents or other asynchronous work. Use this when you are waiting for child-agent updates instead of repeating the same investigation yourself.".to_string(),
|
||||||
|
input_schema: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"idle_timeout_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 0,
|
||||||
|
"description": "Optional idle timeout. 0 lets Galaxy choose the default."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "start_agent".to_string(),
|
name: "start_agent".to_string(),
|
||||||
description: "Start a sub-agent to handle a specific task autonomously. Use for delegating independent work that can run in parallel. The agent gets its own conversation context and tool access. IMPORTANT: Only use this for the initial investigation or when genuinely new research is needed. Do NOT re-spawn agents for follow-up questions if you already have their output in context — just answer from the information you already have.".to_string(),
|
description: "Start a sub-agent to handle a specific task autonomously. Use for delegating independent work that can run in parallel. The agent gets its own conversation context and tool access. IMPORTANT: Only use this for the initial investigation or when genuinely new research is needed. Do NOT re-spawn agents for follow-up questions if you already have their output in context — just answer from the information you already have.".to_string(),
|
||||||
@@ -2380,10 +2499,12 @@ fn long_running_command_content(snapshot: &api::LongRunningShellCommandSnapshot)
|
|||||||
};
|
};
|
||||||
format!(
|
format!(
|
||||||
"Command is still running.\nCommand ID: {}\nCurrent terminal output:\n{}\n\
|
"Command is still running.\nCommand ID: {}\nCurrent terminal output:\n{}\n\
|
||||||
Continue monitoring with `read_shell_command_output` using command_id `{}`. \
|
The next assistant output MUST be a tool call: continue monitoring with \
|
||||||
Use `write_to_long_running_shell_command` with the same command_id only if input is \
|
`read_shell_command_output` using command_id `{}` and a short wait. Use \
|
||||||
required. If the user's explicit stop condition is met, use `interrupt_shell_command` \
|
`write_to_long_running_shell_command` with the same command_id only if input is required. \
|
||||||
with the same command_id. Do not report the command as complete while it is still running.",
|
If the user's explicit stop condition is met, use `interrupt_shell_command` immediately \
|
||||||
|
with the same command_id. Do not end this turn with prose or report the command as complete \
|
||||||
|
while it is still running.",
|
||||||
snapshot.command_id, output, snapshot.command_id
|
snapshot.command_id, output, snapshot.command_id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use super::{
|
|||||||
convert_proto_message_for_test, extract_new_input_messages, extract_system_prompt,
|
convert_proto_message_for_test, extract_new_input_messages, extract_system_prompt,
|
||||||
extract_tools, inject_input_messages_into_task, sanitize_messages_for_bedrock,
|
extract_tools, inject_input_messages_into_task, sanitize_messages_for_bedrock,
|
||||||
};
|
};
|
||||||
|
use crate::ai::agent::api::is_internal_command_completion_assessment;
|
||||||
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
|
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -99,6 +100,7 @@ fn advertised_tools_follow_client_capabilities_and_include_local_subagents() {
|
|||||||
vec![
|
vec![
|
||||||
"run_shell_command",
|
"run_shell_command",
|
||||||
"read_files",
|
"read_files",
|
||||||
|
"run_agents",
|
||||||
"start_agent",
|
"start_agent",
|
||||||
"recall_tool_history"
|
"recall_tool_history"
|
||||||
]
|
]
|
||||||
@@ -236,6 +238,117 @@ fn plan_mode_prompt_prohibits_mutation() {
|
|||||||
assert!(prompt.contains("do not edit files"));
|
assert!(prompt.contains("do not edit files"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn completed_command_request() -> api::Request {
|
||||||
|
api::Request {
|
||||||
|
task_context: Some(api::request::TaskContext {
|
||||||
|
tasks: vec![api::Task {
|
||||||
|
id: "root-task".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
input: Some(api::request::Input {
|
||||||
|
r#type: Some(api::request::input::Type::UserInputs(
|
||||||
|
api::request::input::UserInputs {
|
||||||
|
inputs: vec![api::request::input::user_inputs::UserInput {
|
||||||
|
input: Some(
|
||||||
|
api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
|
||||||
|
api::request::input::CliAgentUserQuery {
|
||||||
|
user_query: Some(api::request::input::UserQuery {
|
||||||
|
query: "Report the final outcome.".to_string(),
|
||||||
|
intended_agent: api::AgentType::Primary.into(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
running_command: Some(api::RunningShellCommand {
|
||||||
|
command: "cargo test -p galaxy".to_string(),
|
||||||
|
snapshot: Some(api::LongRunningShellCommandSnapshot {
|
||||||
|
command_id: "completed-block-123".to_string(),
|
||||||
|
output: "test result: ok".to_string(),
|
||||||
|
cursor: "cursor".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
settings: Some(api::request::Settings {
|
||||||
|
supported_tools: vec![
|
||||||
|
api::ToolType::RunShellCommand.into(),
|
||||||
|
api::ToolType::ReadFiles.into(),
|
||||||
|
api::ToolType::CallMcpTool.into(),
|
||||||
|
],
|
||||||
|
supported_cli_agent_tools: vec![api::ToolType::ReadShellCommandOutput.into()],
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
mcp_context: Some(api::request::McpContext {
|
||||||
|
servers: vec![api::request::mcp_context::McpServer {
|
||||||
|
id: "server-id".to_string(),
|
||||||
|
name: "test-server".to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
resources: Vec::new(),
|
||||||
|
tools: vec![api::request::mcp_context::McpTool {
|
||||||
|
name: "echo".to_string(),
|
||||||
|
description: "Echo input".to_string(),
|
||||||
|
input_schema: None,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_command_assessment_is_tool_free_and_persists_hidden_provider_history() {
|
||||||
|
let mut request = completed_command_request();
|
||||||
|
|
||||||
|
let messages = extract_new_input_messages(&request);
|
||||||
|
assert_eq!(messages.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
&messages[0].content,
|
||||||
|
MessageContent::Text(text)
|
||||||
|
if text.contains("[Completed command: cargo test -p galaxy]")
|
||||||
|
&& text.contains("[Command ID: completed-block-123]")
|
||||||
|
&& text.contains("[Final terminal output:\ntest result: ok")
|
||||||
|
&& text.contains("Report the final outcome.")
|
||||||
|
));
|
||||||
|
|
||||||
|
let prompt = extract_system_prompt(&request, &[]).expect("system prompt");
|
||||||
|
assert!(prompt.contains("## Completed Command Assessment"));
|
||||||
|
assert!(prompt.contains("No tools are available for this request"));
|
||||||
|
assert!(!prompt.contains("## Running Command Monitor"));
|
||||||
|
assert!(!prompt.contains("next assistant output MUST be a tool call"));
|
||||||
|
assert!(extract_tools(&request).is_empty());
|
||||||
|
|
||||||
|
inject_input_messages_into_task(&mut request);
|
||||||
|
let persisted = &request.task_context.as_ref().expect("task context").tasks[0].messages;
|
||||||
|
assert_eq!(persisted.len(), 1);
|
||||||
|
assert!(is_internal_command_completion_assessment(&persisted[0]));
|
||||||
|
assert!(matches!(
|
||||||
|
persisted[0].message.as_ref(),
|
||||||
|
Some(api::message::Message::UserQuery(query))
|
||||||
|
if query.query.contains("[Completed command: cargo test -p galaxy]")
|
||||||
|
&& query.query.contains("[Command ID: completed-block-123]")
|
||||||
|
&& query.query.contains("[Final terminal output:\ntest result: ok")
|
||||||
|
&& query.query.contains("Report the final outcome.")
|
||||||
|
));
|
||||||
|
|
||||||
|
let restored = convert_proto_message_for_test(&persisted[0])
|
||||||
|
.expect("hidden assessment should remain in provider history");
|
||||||
|
assert_eq!(restored.role, MessageRole::User);
|
||||||
|
assert!(matches!(
|
||||||
|
restored.content,
|
||||||
|
MessageContent::Text(text)
|
||||||
|
if text.contains("[Completed command: cargo test -p galaxy]")
|
||||||
|
&& text.contains("Report the final outcome.")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn running_command_turn_gets_monitor_prompt_and_cli_tools() {
|
fn running_command_turn_gets_monitor_prompt_and_cli_tools() {
|
||||||
let request = api::Request {
|
let request = api::Request {
|
||||||
@@ -299,6 +412,10 @@ fn running_command_turn_gets_monitor_prompt_and_cli_tools() {
|
|||||||
assert!(prompt.contains("command ID"));
|
assert!(prompt.contains("command ID"));
|
||||||
assert!(prompt.contains("read_shell_command_output"));
|
assert!(prompt.contains("read_shell_command_output"));
|
||||||
assert!(prompt.contains("interrupt_shell_command"));
|
assert!(prompt.contains("interrupt_shell_command"));
|
||||||
|
assert!(prompt.contains("next assistant output MUST be a tool call"));
|
||||||
|
assert!(prompt.contains("alternate screen containing `(END)` is `less`"));
|
||||||
|
assert!(prompt.contains("`write_to_long_running_shell_command` with input `q` and mode `raw`"));
|
||||||
|
assert!(prompt.contains("Do not end a still-running monitor turn with prose"));
|
||||||
assert!(prompt.contains("Never try to encode Ctrl+C"));
|
assert!(prompt.contains("Never try to encode Ctrl+C"));
|
||||||
assert!(!prompt.contains("- Use `run_shell_command`"));
|
assert!(!prompt.contains("- Use `run_shell_command`"));
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ use aws_sdk_bedrockruntime::types::{
|
|||||||
ReasoningContentBlockDelta, StopReason,
|
ReasoningContentBlockDelta, StopReason,
|
||||||
};
|
};
|
||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
|
use galaxy_agent_core::{recall_tool_history, ToolHistoryQuery};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use warp_multi_agent_api::response_event::stream_finished;
|
use warp_multi_agent_api::response_event::stream_finished;
|
||||||
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
|
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
|
||||||
|
|
||||||
use super::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
|
use super::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
|
||||||
use super::diagnostic::BedrockDiagnosticLogger;
|
use super::diagnostic::BedrockDiagnosticLogger;
|
||||||
use crate::ai::agent::api::Event;
|
use crate::ai::agent::api::LegacyEvent;
|
||||||
use crate::server::server_api::AIApiError;
|
use crate::server::server_api::AIApiError;
|
||||||
|
|
||||||
fn json_to_prost_struct(value: &serde_json::Value) -> prost_types::Struct {
|
fn json_to_prost_struct(value: &serde_json::Value) -> prost_types::Struct {
|
||||||
@@ -68,7 +69,7 @@ pub fn bedrock_stream_to_response_events(
|
|||||||
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
|
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
|
||||||
model_id: String,
|
model_id: String,
|
||||||
tool_result_archive: Vec<ConversationMessage>,
|
tool_result_archive: Vec<ConversationMessage>,
|
||||||
) -> BoxStream<'static, Event> {
|
) -> BoxStream<'static, LegacyEvent> {
|
||||||
let request_id = Uuid::new_v4().to_string();
|
let request_id = Uuid::new_v4().to_string();
|
||||||
let conversation_id = Uuid::new_v4().to_string();
|
let conversation_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
@@ -231,13 +232,15 @@ pub fn bedrock_stream_to_response_events(
|
|||||||
.unwrap_or(0) as usize;
|
.unwrap_or(0) as usize;
|
||||||
|
|
||||||
let recall_result = match messages_sent.lock() {
|
let recall_result = match messages_sent.lock() {
|
||||||
Ok(sent) => recall_from_history(
|
Ok(sent) => recall_tool_history(
|
||||||
&sent,
|
&sent,
|
||||||
&tool_result_archive,
|
&tool_result_archive,
|
||||||
search_query,
|
ToolHistoryQuery {
|
||||||
tool_name_filter,
|
search_query,
|
||||||
tool_use_id,
|
tool_name: tool_name_filter,
|
||||||
offset,
|
tool_use_id,
|
||||||
|
offset_from_end: offset,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
Err(_) => "Error: could not access conversation history.".to_string(),
|
Err(_) => "Error: could not access conversation history.".to_string(),
|
||||||
};
|
};
|
||||||
@@ -319,7 +322,7 @@ pub fn bedrock_stream_to_response_events(
|
|||||||
current_tool_name, current_tool_use_id, current_tool_input_json
|
current_tool_name, current_tool_use_id, current_tool_input_json
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if current_tool_name == "start_agent" {
|
if matches!(current_tool_name.as_str(), "start_agent" | "run_agents") {
|
||||||
has_start_agent_calls = true;
|
has_start_agent_calls = true;
|
||||||
}
|
}
|
||||||
let tool_msg = build_tool_call_message(
|
let tool_msg = build_tool_call_message(
|
||||||
@@ -1241,6 +1244,78 @@ pub fn build_tool_call_message(
|
|||||||
api::message::tool_call::EditDocuments { diffs },
|
api::message::tool_call::EditDocuments { diffs },
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
"run_agents" => {
|
||||||
|
let agent_run_configs = input
|
||||||
|
.get("agent_run_configs")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.map(|configs| {
|
||||||
|
configs
|
||||||
|
.iter()
|
||||||
|
.map(|config| api::run_agents::AgentRunConfig {
|
||||||
|
name: config
|
||||||
|
.get("name")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
prompt: config
|
||||||
|
.get("prompt")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
title: config
|
||||||
|
.get("title")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let execution_mode = input
|
||||||
|
.get("execution_mode")
|
||||||
|
.and_then(run_agents_execution_mode_from_json);
|
||||||
|
Some(api::message::tool_call::Tool::RunAgents(api::RunAgents {
|
||||||
|
summary: input
|
||||||
|
.get("summary")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
base_prompt: input
|
||||||
|
.get("base_prompt")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
skills: Vec::new(),
|
||||||
|
model_id: input
|
||||||
|
.get("model_id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
harness: input
|
||||||
|
.get("harness_type")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.and_then(run_agents_harness_from_str),
|
||||||
|
agent_run_configs,
|
||||||
|
execution_mode,
|
||||||
|
plan_id: input
|
||||||
|
.get("plan_id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"wait_for_events" => {
|
||||||
|
let idle_timeout_seconds = input
|
||||||
|
.get("idle_timeout_seconds")
|
||||||
|
.and_then(|value| value.as_i64())
|
||||||
|
.and_then(|value| value.try_into().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
Some(api::message::tool_call::Tool::WaitForEvents(
|
||||||
|
api::message::tool_call::WaitForEvents {
|
||||||
|
idle_timeout_seconds,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
"start_agent" => {
|
"start_agent" => {
|
||||||
let name = input
|
let name = input
|
||||||
.get("name")
|
.get("name")
|
||||||
@@ -1467,6 +1542,58 @@ pub fn build_tool_call_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_agents_execution_mode_from_json(
|
||||||
|
execution_mode: &serde_json::Value,
|
||||||
|
) -> Option<api::run_agents::ExecutionMode> {
|
||||||
|
let mode_type = execution_mode
|
||||||
|
.get("type")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.or_else(|| execution_mode.as_str());
|
||||||
|
match mode_type {
|
||||||
|
Some("remote") => Some(api::run_agents::ExecutionMode::Remote(
|
||||||
|
api::run_agents::Remote {
|
||||||
|
environment_id: execution_mode
|
||||||
|
.get("environment_id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
worker_host: execution_mode
|
||||||
|
.get("worker_host")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
computer_use_enabled: execution_mode
|
||||||
|
.get("computer_use_enabled")
|
||||||
|
.and_then(|value| value.as_bool())
|
||||||
|
.unwrap_or(false),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
Some("local") | Some(_) | None => Some(api::run_agents::ExecutionMode::Local(
|
||||||
|
api::run_agents::Local {},
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_agents_harness_from_str(harness_type: &str) -> Option<api::Harness> {
|
||||||
|
let variant = match harness_type
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.replace('_', "-")
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"oz" => api::harness::Variant::Oz(api::harness::Oz {}),
|
||||||
|
"claude" | "claude-code" => api::harness::Variant::ClaudeCode(api::harness::ClaudeCode {}),
|
||||||
|
"opencode" | "open-code" => api::harness::Variant::OpenCode(api::harness::OpenCode {}),
|
||||||
|
"gemini" => api::harness::Variant::Gemini(api::harness::Gemini {}),
|
||||||
|
"codex" => api::harness::Variant::Codex(api::harness::Codex {}),
|
||||||
|
"" | "unknown" => return None,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(api::Harness {
|
||||||
|
variant: Some(variant),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Built-in tools that Galaxy knows how to execute directly.
|
/// Built-in tools that Galaxy knows how to execute directly.
|
||||||
const KNOWN_TOOLS: &[&str] = &[
|
const KNOWN_TOOLS: &[&str] = &[
|
||||||
"run_shell_command",
|
"run_shell_command",
|
||||||
@@ -1490,6 +1617,8 @@ const KNOWN_TOOLS: &[&str] = &[
|
|||||||
"read_documents",
|
"read_documents",
|
||||||
"create_documents",
|
"create_documents",
|
||||||
"edit_documents",
|
"edit_documents",
|
||||||
|
"run_agents",
|
||||||
|
"wait_for_events",
|
||||||
"start_agent",
|
"start_agent",
|
||||||
"ask_user_question",
|
"ask_user_question",
|
||||||
"read_skill",
|
"read_skill",
|
||||||
@@ -1504,137 +1633,3 @@ pub(super) fn is_known_tool(name: &str) -> bool {
|
|||||||
fn is_notebook_tool(name: &str) -> bool {
|
fn is_notebook_tool(name: &str) -> bool {
|
||||||
matches!(name, "create_notebook" | "read_notebook" | "edit_notebook")
|
matches!(name, "create_notebook" | "read_notebook" | "edit_notebook")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Searches conversation message history for tool call results matching the given criteria.
|
|
||||||
pub(crate) fn recall_from_history(
|
|
||||||
messages: &[ConversationMessage],
|
|
||||||
archive: &[ConversationMessage],
|
|
||||||
search_query: &str,
|
|
||||||
tool_name_filter: &str,
|
|
||||||
tool_use_id: &str,
|
|
||||||
offset_from_end: usize,
|
|
||||||
) -> String {
|
|
||||||
use super::convert::{ContentPart, MessageContent};
|
|
||||||
|
|
||||||
struct ToolEntry {
|
|
||||||
tool_use_id: String,
|
|
||||||
name: String,
|
|
||||||
input: String,
|
|
||||||
result: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tool_entries: Vec<ToolEntry> = Vec::new();
|
|
||||||
|
|
||||||
let mut pending_tool_uses: Vec<(String, String, String)> = Vec::new(); // (id, name, input)
|
|
||||||
|
|
||||||
for msg in messages.iter().chain(archive.iter()) {
|
|
||||||
match &msg.content {
|
|
||||||
MessageContent::ToolUse {
|
|
||||||
tool_use_id,
|
|
||||||
name,
|
|
||||||
input,
|
|
||||||
} => {
|
|
||||||
pending_tool_uses.push((tool_use_id.clone(), name.clone(), input.to_string()));
|
|
||||||
}
|
|
||||||
MessageContent::ToolResult {
|
|
||||||
tool_use_id,
|
|
||||||
content,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if let Some(pos) = pending_tool_uses
|
|
||||||
.iter()
|
|
||||||
.position(|(id, _, _)| id == tool_use_id)
|
|
||||||
{
|
|
||||||
let (tuid, name, input) = pending_tool_uses.remove(pos);
|
|
||||||
tool_entries.push(ToolEntry {
|
|
||||||
tool_use_id: tuid,
|
|
||||||
name,
|
|
||||||
input,
|
|
||||||
result: content.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MessageContent::MultiPart(parts) => {
|
|
||||||
for part in parts {
|
|
||||||
match part {
|
|
||||||
ContentPart::ToolUse {
|
|
||||||
tool_use_id,
|
|
||||||
name,
|
|
||||||
input,
|
|
||||||
} => {
|
|
||||||
pending_tool_uses.push((
|
|
||||||
tool_use_id.clone(),
|
|
||||||
name.clone(),
|
|
||||||
input.to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
ContentPart::ToolResult {
|
|
||||||
tool_use_id,
|
|
||||||
content,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if let Some(pos) = pending_tool_uses
|
|
||||||
.iter()
|
|
||||||
.position(|(id, _, _)| id == tool_use_id)
|
|
||||||
{
|
|
||||||
let (tuid, name, input) = pending_tool_uses.remove(pos);
|
|
||||||
tool_entries.push(ToolEntry {
|
|
||||||
tool_use_id: tuid,
|
|
||||||
name,
|
|
||||||
input,
|
|
||||||
result: content.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let filtered: Vec<&ToolEntry> = tool_entries
|
|
||||||
.iter()
|
|
||||||
.filter(|entry| {
|
|
||||||
if !tool_use_id.is_empty() && entry.tool_use_id != tool_use_id {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if !tool_name_filter.is_empty() && entry.name != tool_name_filter {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if !search_query.is_empty() {
|
|
||||||
let haystack = format!("{} {} {}", entry.name, entry.input, entry.result);
|
|
||||||
let query_lower = search_query.to_lowercase();
|
|
||||||
if !haystack.to_lowercase().contains(&query_lower) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if filtered.is_empty() {
|
|
||||||
return "No matching tool calls found in conversation history.".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the entry at offset_from_end (0 = most recent)
|
|
||||||
let idx = if offset_from_end >= filtered.len() {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
filtered.len() - 1 - offset_from_end
|
|
||||||
};
|
|
||||||
|
|
||||||
let entry = &filtered[idx];
|
|
||||||
let result_display = if entry.result.len() > 50000 {
|
|
||||||
let trunc = entry.result.chars().take(50000).collect::<String>();
|
|
||||||
format!("{trunc}... [truncated, {} total chars]", entry.result.len())
|
|
||||||
} else {
|
|
||||||
entry.result.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
format!(
|
|
||||||
"Tool: {}\nTool Use ID: {}\nInput: {}\nResult:\n{}",
|
|
||||||
entry.name, entry.tool_use_id, entry.input, result_display
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -350,6 +350,56 @@ fn development_tool_calls_preserve_focused_reads_file_lifecycle_and_search_filte
|
|||||||
assert_eq!(search.path_filters, vec!["app/src/ai", "crates/ai"]);
|
assert_eq!(search.path_filters, vec!["app/src/ai", "crates/ai"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orchestration_tool_calls_build_run_agents_and_wait_for_events() {
|
||||||
|
let run_tool = tool_from_event(build_tool_call_message(
|
||||||
|
"task-1",
|
||||||
|
"tool-run-agents",
|
||||||
|
"run_agents",
|
||||||
|
r#"{
|
||||||
|
"summary": "Investigate in parallel",
|
||||||
|
"base_prompt": "Shared instructions",
|
||||||
|
"model_id": "coding-assistant-max",
|
||||||
|
"harness_type": "codex",
|
||||||
|
"execution_mode": {
|
||||||
|
"type": "local"
|
||||||
|
},
|
||||||
|
"agent_run_configs": [
|
||||||
|
{
|
||||||
|
"name": "code",
|
||||||
|
"prompt": "Inspect code",
|
||||||
|
"title": "Code inspection"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plan_id": "plan-1"
|
||||||
|
}"#,
|
||||||
|
));
|
||||||
|
let api::message::tool_call::Tool::RunAgents(run_agents) = run_tool else {
|
||||||
|
panic!("expected run_agents");
|
||||||
|
};
|
||||||
|
assert_eq!(run_agents.summary, "Investigate in parallel");
|
||||||
|
assert_eq!(run_agents.base_prompt, "Shared instructions");
|
||||||
|
assert_eq!(run_agents.model_id, "coding-assistant-max");
|
||||||
|
assert!(matches!(
|
||||||
|
run_agents.execution_mode,
|
||||||
|
Some(api::run_agents::ExecutionMode::Local(_))
|
||||||
|
));
|
||||||
|
assert_eq!(run_agents.agent_run_configs.len(), 1);
|
||||||
|
assert_eq!(run_agents.agent_run_configs[0].name, "code");
|
||||||
|
assert_eq!(run_agents.agent_run_configs[0].prompt, "Inspect code");
|
||||||
|
|
||||||
|
let wait_tool = tool_from_event(build_tool_call_message(
|
||||||
|
"task-1",
|
||||||
|
"tool-wait",
|
||||||
|
"wait_for_events",
|
||||||
|
r#"{"idle_timeout_seconds": 120}"#,
|
||||||
|
));
|
||||||
|
let api::message::tool_call::Tool::WaitForEvents(wait) = wait_tool else {
|
||||||
|
panic!("expected wait_for_events");
|
||||||
|
};
|
||||||
|
assert_eq!(wait.idle_timeout_seconds, 120);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_context_window_for_model_1m_marker() {
|
fn test_context_window_for_model_1m_marker() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -467,6 +517,8 @@ fn test_cost_zero_for_zero_tokens() {
|
|||||||
fn direct_provider_known_tools_exclude_hosted_only_tools() {
|
fn direct_provider_known_tools_exclude_hosted_only_tools() {
|
||||||
assert!(!is_known_tool("send_message_to_agent"));
|
assert!(!is_known_tool("send_message_to_agent"));
|
||||||
assert!(!is_known_tool("suggest_next_prompt"));
|
assert!(!is_known_tool("suggest_next_prompt"));
|
||||||
|
assert!(is_known_tool("run_agents"));
|
||||||
|
assert!(is_known_tool("wait_for_events"));
|
||||||
assert!(is_known_tool("recall_tool_history"));
|
assert!(is_known_tool("recall_tool_history"));
|
||||||
assert!(is_known_tool("interrupt_shell_command"));
|
assert!(is_known_tool("interrupt_shell_command"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamOutput;
|
||||||
|
use aws_sdk_bedrockruntime::types::{
|
||||||
|
ContentBlockDelta, ContentBlockStart, ConverseStreamOutput as AwsStreamEvent,
|
||||||
|
ReasoningContentBlockDelta, StopReason as AwsStopReason,
|
||||||
|
};
|
||||||
|
use aws_sdk_bedrockruntime::Client as AwsBedrockClient;
|
||||||
|
use futures::{FutureExt, StreamExt};
|
||||||
|
use galaxy_agent_core::{
|
||||||
|
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, RuntimeCapabilities,
|
||||||
|
RuntimeDescriptor, RuntimeKind, StopReason, ToolCall, ToolEvent, TurnCommand, TurnControl,
|
||||||
|
TurnRequest, Usage,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::convert::{build_converse_request, CachingConfig, ConvertedRequest};
|
||||||
|
|
||||||
|
const DEFAULT_MAX_OUTPUT_TOKENS: u64 = 64_000;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct BedrockAgentRuntime {
|
||||||
|
client: AwsBedrockClient,
|
||||||
|
resolved_model: String,
|
||||||
|
max_output_tokens: Option<u64>,
|
||||||
|
caching_config: CachingConfig,
|
||||||
|
descriptor: RuntimeDescriptor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BedrockAgentRuntime {
|
||||||
|
pub(crate) fn new(
|
||||||
|
client: AwsBedrockClient,
|
||||||
|
configured_model: String,
|
||||||
|
region: String,
|
||||||
|
cross_region_inference: bool,
|
||||||
|
max_output_tokens: Option<u64>,
|
||||||
|
caching_config: CachingConfig,
|
||||||
|
) -> Result<Self, AgentError> {
|
||||||
|
let resolved_model = galaxy_agent_rig::resolve_bedrock_model_id(
|
||||||
|
&configured_model,
|
||||||
|
®ion,
|
||||||
|
cross_region_inference,
|
||||||
|
)?;
|
||||||
|
let descriptor = RuntimeDescriptor {
|
||||||
|
id: format!("bedrock:{resolved_model}"),
|
||||||
|
display_name: format!("Bedrock / {resolved_model}"),
|
||||||
|
kind: RuntimeKind::Provider,
|
||||||
|
capabilities: RuntimeCapabilities::provider(),
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
resolved_model,
|
||||||
|
max_output_tokens,
|
||||||
|
caching_config,
|
||||||
|
descriptor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentRuntime for BedrockAgentRuntime {
|
||||||
|
fn descriptor(&self) -> &RuntimeDescriptor {
|
||||||
|
&self.descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_turn(
|
||||||
|
&self,
|
||||||
|
request: TurnRequest,
|
||||||
|
control: TurnControl,
|
||||||
|
) -> Result<AgentEventStream, AgentError> {
|
||||||
|
let converted =
|
||||||
|
convert_turn_request(request, self.max_output_tokens, self.caching_config.clone());
|
||||||
|
let mut request = self
|
||||||
|
.client
|
||||||
|
.converse_stream()
|
||||||
|
.model_id(&self.resolved_model)
|
||||||
|
.set_system(Some(converted.system))
|
||||||
|
.set_messages(Some(converted.messages))
|
||||||
|
.inference_config(converted.inference_config);
|
||||||
|
if let Some(tool_config) = converted.tool_config {
|
||||||
|
request = request.tool_config(tool_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime_request_id = Uuid::new_v4().to_string();
|
||||||
|
let send_future = request.send().fuse();
|
||||||
|
let initial_control = control.clone();
|
||||||
|
let control_future = initial_control.receive().fuse();
|
||||||
|
futures::pin_mut!(send_future, control_future);
|
||||||
|
let output = futures::select_biased! {
|
||||||
|
command = control_future => match command {
|
||||||
|
Ok(TurnCommand::Cancel) => {
|
||||||
|
return Ok(stopped_before_stream(runtime_request_id));
|
||||||
|
}
|
||||||
|
Ok(TurnCommand::Steer { .. }) | Err(_) => {
|
||||||
|
send_future.await.map_err(map_bedrock_error)?
|
||||||
|
}
|
||||||
|
},
|
||||||
|
result = send_future => result.map_err(map_bedrock_error)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(translate_bedrock_stream(
|
||||||
|
output,
|
||||||
|
runtime_request_id,
|
||||||
|
control,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_turn_request(
|
||||||
|
request: TurnRequest,
|
||||||
|
configured_max_output_tokens: Option<u64>,
|
||||||
|
caching_config: CachingConfig,
|
||||||
|
) -> ConvertedRequest {
|
||||||
|
let max_output_tokens = request
|
||||||
|
.max_output_tokens
|
||||||
|
.or(configured_max_output_tokens)
|
||||||
|
.unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||||
|
.min(i32::MAX as u64) as i32;
|
||||||
|
build_converse_request(
|
||||||
|
request.messages,
|
||||||
|
request.system_prompt,
|
||||||
|
None,
|
||||||
|
request.tools,
|
||||||
|
max_output_tokens,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
caching_config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn translate_bedrock_stream(
|
||||||
|
mut output: ConverseStreamOutput,
|
||||||
|
runtime_request_id: String,
|
||||||
|
control: TurnControl,
|
||||||
|
) -> AgentEventStream {
|
||||||
|
let events = async_stream::stream! {
|
||||||
|
yield Ok(AgentEvent::TurnStarted {
|
||||||
|
runtime_request_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut translator = BedrockStreamTranslator::default();
|
||||||
|
let mut control_open = true;
|
||||||
|
loop {
|
||||||
|
let next_event = output.stream.recv().fuse();
|
||||||
|
let next_command = if control_open {
|
||||||
|
futures::future::Either::Left(control.receive())
|
||||||
|
} else {
|
||||||
|
futures::future::Either::Right(futures::future::pending())
|
||||||
|
}
|
||||||
|
.fuse();
|
||||||
|
futures::pin_mut!(next_event, next_command);
|
||||||
|
|
||||||
|
let event = futures::select_biased! {
|
||||||
|
command = next_command => {
|
||||||
|
match command {
|
||||||
|
Ok(TurnCommand::Cancel) => {
|
||||||
|
yield Ok(AgentEvent::TurnStopped {
|
||||||
|
reason: StopReason::Cancelled,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(TurnCommand::Steer { .. }) => continue,
|
||||||
|
Err(_) => {
|
||||||
|
control_open = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = next_event => event,
|
||||||
|
};
|
||||||
|
|
||||||
|
match event {
|
||||||
|
Ok(Some(event)) => match translator.translate(event) {
|
||||||
|
Ok(events) => {
|
||||||
|
for event in events {
|
||||||
|
yield Ok(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
yield Err(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(None) => match translator.finish() {
|
||||||
|
Ok(events) => {
|
||||||
|
for event in events {
|
||||||
|
yield Ok(event);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
yield Err(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(error) => {
|
||||||
|
yield Err(map_bedrock_error(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Box::pin(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct BedrockStreamTranslator {
|
||||||
|
content_blocks: BTreeMap<i32, PendingContentBlock>,
|
||||||
|
stop_reason: Option<StopReason>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BedrockStreamTranslator {
|
||||||
|
fn translate(&mut self, event: AwsStreamEvent) -> Result<Vec<AgentEvent>, AgentError> {
|
||||||
|
match event {
|
||||||
|
AwsStreamEvent::MessageStart(_) => Ok(Vec::new()),
|
||||||
|
AwsStreamEvent::ContentBlockStart(start) => {
|
||||||
|
let Some(block_start) = start.start() else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let ContentBlockStart::ToolUse(tool) = block_start else {
|
||||||
|
return Err(protocol_error(
|
||||||
|
"Bedrock started an unsupported output content block",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let index = start.content_block_index();
|
||||||
|
if self
|
||||||
|
.content_blocks
|
||||||
|
.insert(
|
||||||
|
index,
|
||||||
|
PendingContentBlock::Tool {
|
||||||
|
id: tool.tool_use_id().to_string(),
|
||||||
|
name: tool.name().to_string(),
|
||||||
|
input: String::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(protocol_error(format!(
|
||||||
|
"Bedrock started content block {index} more than once"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
AwsStreamEvent::ContentBlockDelta(delta) => {
|
||||||
|
let Some(delta_value) = delta.delta() else {
|
||||||
|
return Err(protocol_error("Bedrock emitted an empty content delta"));
|
||||||
|
};
|
||||||
|
let index = delta.content_block_index();
|
||||||
|
match delta_value {
|
||||||
|
ContentBlockDelta::Text(text) => {
|
||||||
|
Ok(vec![AgentEvent::TextDelta { text: text.clone() }])
|
||||||
|
}
|
||||||
|
ContentBlockDelta::ReasoningContent(reasoning) => {
|
||||||
|
let block = self.content_blocks.entry(index).or_insert_with(|| {
|
||||||
|
PendingContentBlock::Reasoning {
|
||||||
|
text: String::new(),
|
||||||
|
signature: None,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let PendingContentBlock::Reasoning { text, signature } = block else {
|
||||||
|
return Err(protocol_error(format!(
|
||||||
|
"Bedrock mixed reasoning and tool data in content block {index}"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
match reasoning {
|
||||||
|
ReasoningContentBlockDelta::Text(delta) => {
|
||||||
|
text.push_str(delta);
|
||||||
|
Ok(vec![AgentEvent::ReasoningDelta {
|
||||||
|
text: delta.clone(),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
ReasoningContentBlockDelta::Signature(delta) => {
|
||||||
|
signature.get_or_insert_with(String::new).push_str(delta);
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
ReasoningContentBlockDelta::RedactedContent(_) => Ok(Vec::new()),
|
||||||
|
_ => Err(protocol_error("Bedrock emitted an unknown reasoning delta")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ContentBlockDelta::ToolUse(tool_delta) => {
|
||||||
|
let Some(PendingContentBlock::Tool { input, .. }) =
|
||||||
|
self.content_blocks.get_mut(&index)
|
||||||
|
else {
|
||||||
|
return Err(protocol_error(format!(
|
||||||
|
"Bedrock emitted tool input before starting content block {index}"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
input.push_str(tool_delta.input());
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
ContentBlockDelta::Citation(_) => Ok(Vec::new()),
|
||||||
|
ContentBlockDelta::Image(_) => {
|
||||||
|
Err(protocol_error("Bedrock emitted unsupported image output"))
|
||||||
|
}
|
||||||
|
ContentBlockDelta::ToolResult(_) => Err(protocol_error(
|
||||||
|
"Bedrock emitted an unexpected tool-result delta",
|
||||||
|
)),
|
||||||
|
_ => Err(protocol_error("Bedrock emitted an unknown content delta")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AwsStreamEvent::ContentBlockStop(stop) => {
|
||||||
|
let index = stop.content_block_index();
|
||||||
|
let Some(block) = self.content_blocks.remove(&index) else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
match block {
|
||||||
|
PendingContentBlock::Tool { id, name, input } => {
|
||||||
|
let arguments = serde_json::from_str(&input).map_err(|error| {
|
||||||
|
protocol_error(format!(
|
||||||
|
"Bedrock returned invalid JSON for tool '{name}' ({id}): {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(vec![AgentEvent::Tool {
|
||||||
|
event: ToolEvent::Proposed {
|
||||||
|
call: ToolCall {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
arguments,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
PendingContentBlock::Reasoning { text, signature } => {
|
||||||
|
Ok(vec![AgentEvent::ReasoningCompleted { text, signature }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AwsStreamEvent::MessageStop(stop) => {
|
||||||
|
if self.stop_reason.is_some() {
|
||||||
|
return Err(protocol_error(
|
||||||
|
"Bedrock emitted more than one message-stop event",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.stop_reason = Some(map_stop_reason(stop.stop_reason()));
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
AwsStreamEvent::Metadata(metadata) => {
|
||||||
|
let Some(usage) = metadata.usage() else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
Ok(vec![AgentEvent::UsageUpdated {
|
||||||
|
usage: Usage {
|
||||||
|
input_tokens: nonnegative_tokens(usage.input_tokens()),
|
||||||
|
output_tokens: nonnegative_tokens(usage.output_tokens()),
|
||||||
|
cached_input_tokens: nonnegative_tokens(
|
||||||
|
usage.cache_read_input_tokens().unwrap_or(0),
|
||||||
|
),
|
||||||
|
cache_creation_input_tokens: nonnegative_tokens(
|
||||||
|
usage.cache_write_input_tokens().unwrap_or(0),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
_ => Err(protocol_error("Bedrock emitted an unknown stream event")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(self) -> Result<Vec<AgentEvent>, AgentError> {
|
||||||
|
if !self.content_blocks.is_empty() {
|
||||||
|
return Err(protocol_error(
|
||||||
|
"Bedrock stream ended with incomplete content blocks",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let reason = self
|
||||||
|
.stop_reason
|
||||||
|
.ok_or_else(|| protocol_error("Bedrock stream ended before the message-stop event"))?;
|
||||||
|
Ok(vec![AgentEvent::TurnStopped { reason }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum PendingContentBlock {
|
||||||
|
Tool {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: String,
|
||||||
|
},
|
||||||
|
Reasoning {
|
||||||
|
text: String,
|
||||||
|
signature: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_stop_reason(reason: &AwsStopReason) -> StopReason {
|
||||||
|
match reason {
|
||||||
|
AwsStopReason::EndTurn | AwsStopReason::StopSequence | AwsStopReason::ToolUse => {
|
||||||
|
StopReason::Completed
|
||||||
|
}
|
||||||
|
AwsStopReason::MaxTokens => StopReason::MaxTokens,
|
||||||
|
AwsStopReason::ModelContextWindowExceeded => StopReason::ContextWindowExceeded,
|
||||||
|
AwsStopReason::ContentFiltered | AwsStopReason::GuardrailIntervened => StopReason::Refusal,
|
||||||
|
AwsStopReason::MalformedModelOutput | AwsStopReason::MalformedToolUse => {
|
||||||
|
StopReason::Other(reason.as_str().to_string())
|
||||||
|
}
|
||||||
|
other => StopReason::Other(other.as_str().to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nonnegative_tokens(value: i32) -> u64 {
|
||||||
|
u64::try_from(value).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stopped_before_stream(runtime_request_id: String) -> AgentEventStream {
|
||||||
|
Box::pin(futures::stream::iter([
|
||||||
|
Ok(AgentEvent::TurnStarted { runtime_request_id }),
|
||||||
|
Ok(AgentEvent::TurnStopped {
|
||||||
|
reason: StopReason::Cancelled,
|
||||||
|
}),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_bedrock_error(error: impl std::fmt::Display + std::fmt::Debug) -> AgentError {
|
||||||
|
let display = error.to_string();
|
||||||
|
let debug = format!("{error:?}");
|
||||||
|
let message = if debug.len() > display.len() {
|
||||||
|
debug
|
||||||
|
} else {
|
||||||
|
display
|
||||||
|
};
|
||||||
|
let normalized = message.to_ascii_lowercase();
|
||||||
|
let kind = if normalized.contains("accessdenied")
|
||||||
|
|| normalized.contains("access denied")
|
||||||
|
|| normalized.contains("unauthorized")
|
||||||
|
|| normalized.contains("credential")
|
||||||
|
{
|
||||||
|
AgentErrorKind::Authentication
|
||||||
|
} else if normalized.contains("throttl") || normalized.contains("rate limit") {
|
||||||
|
AgentErrorKind::RateLimited
|
||||||
|
} else if normalized.contains("context window")
|
||||||
|
|| normalized.contains("too many tokens")
|
||||||
|
|| normalized.contains("modelcontextwindowexceeded")
|
||||||
|
{
|
||||||
|
AgentErrorKind::ContextWindowExceeded
|
||||||
|
} else if normalized.contains("validation")
|
||||||
|
|| normalized.contains("resource not found")
|
||||||
|
|| normalized.contains("resourcenotfound")
|
||||||
|
{
|
||||||
|
AgentErrorKind::InvalidRequest
|
||||||
|
} else if normalized.contains("timeout")
|
||||||
|
|| normalized.contains("dispatchfailure")
|
||||||
|
|| normalized.contains("connection")
|
||||||
|
{
|
||||||
|
AgentErrorKind::Transport
|
||||||
|
} else {
|
||||||
|
AgentErrorKind::Provider
|
||||||
|
};
|
||||||
|
let mut error = AgentError::new(kind, message);
|
||||||
|
error.recoverable = matches!(
|
||||||
|
kind,
|
||||||
|
AgentErrorKind::RateLimited | AgentErrorKind::Transport
|
||||||
|
);
|
||||||
|
error
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_error(message: impl Into<String>) -> AgentError {
|
||||||
|
AgentError::new(AgentErrorKind::Protocol, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "runtime_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
use aws_sdk_bedrockruntime::types::{
|
||||||
|
CacheTtl, ContentBlock, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStart,
|
||||||
|
ContentBlockStartEvent, ContentBlockStopEvent, ConverseStreamMetadataEvent,
|
||||||
|
ConverseStreamOutput as AwsStreamEvent, MessageStopEvent, ReasoningContentBlockDelta,
|
||||||
|
StopReason as AwsStopReason, SystemContentBlock, TokenUsage, Tool, ToolUseBlockDelta,
|
||||||
|
ToolUseBlockStart,
|
||||||
|
};
|
||||||
|
use galaxy_agent_core::{
|
||||||
|
AgentErrorKind, AgentEvent, ConversationMessage, MessageContent, MessageRole, StopReason,
|
||||||
|
ToolDefinition, ToolEvent, TurnRequest, Usage,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn turn_request() -> TurnRequest {
|
||||||
|
let mut request = TurnRequest::new(
|
||||||
|
"anthropic.claude-test",
|
||||||
|
vec![
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text("first".to_string()),
|
||||||
|
},
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::Text("response".to_string()),
|
||||||
|
},
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text("continue".to_string()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
request.system_prompt = Some("system".to_string());
|
||||||
|
request.tools = vec![ToolDefinition {
|
||||||
|
name: "read_files".to_string(),
|
||||||
|
description: "Read files".to_string(),
|
||||||
|
input_schema: json!({"type": "object"}),
|
||||||
|
}];
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_ttls(converted: &ConvertedRequest) -> Vec<Option<CacheTtl>> {
|
||||||
|
let mut ttls = Vec::new();
|
||||||
|
for message in &converted.messages {
|
||||||
|
for block in message.content() {
|
||||||
|
if let ContentBlock::CachePoint(point) = block {
|
||||||
|
ttls.push(point.ttl().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for block in &converted.system {
|
||||||
|
if let SystemContentBlock::CachePoint(point) = block {
|
||||||
|
ttls.push(point.ttl().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(tool_config) = &converted.tool_config {
|
||||||
|
for tool in tool_config.tools() {
|
||||||
|
if let Tool::CachePoint(point) = tool {
|
||||||
|
ttls.push(point.ttl().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ttls
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_turn_transport_preserves_disabled_default_and_one_hour_cache_modes() {
|
||||||
|
let disabled = convert_turn_request(
|
||||||
|
turn_request(),
|
||||||
|
Some(4096),
|
||||||
|
CachingConfig {
|
||||||
|
enabled: false,
|
||||||
|
extended_ttl_requested: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(cache_ttls(&disabled).is_empty());
|
||||||
|
|
||||||
|
let default = convert_turn_request(turn_request(), Some(4096), CachingConfig::default());
|
||||||
|
assert_eq!(cache_ttls(&default), vec![None, None, None]);
|
||||||
|
|
||||||
|
let one_hour = convert_turn_request(
|
||||||
|
turn_request(),
|
||||||
|
Some(4096),
|
||||||
|
CachingConfig {
|
||||||
|
enabled: true,
|
||||||
|
extended_ttl_requested: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cache_ttls(&one_hour),
|
||||||
|
vec![
|
||||||
|
Some(CacheTtl::OneHour),
|
||||||
|
Some(CacheTtl::OneHour),
|
||||||
|
Some(CacheTtl::OneHour),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_turn_transport_prefers_request_output_limit() {
|
||||||
|
let mut request = turn_request();
|
||||||
|
request.max_output_tokens = Some(8192);
|
||||||
|
let converted = convert_turn_request(request, Some(4096), CachingConfig::default());
|
||||||
|
assert_eq!(converted.inference_config.max_tokens(), Some(8192));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_start(index: i32, id: &str, name: &str) -> AwsStreamEvent {
|
||||||
|
AwsStreamEvent::ContentBlockStart(
|
||||||
|
ContentBlockStartEvent::builder()
|
||||||
|
.content_block_index(index)
|
||||||
|
.start(ContentBlockStart::ToolUse(
|
||||||
|
ToolUseBlockStart::builder()
|
||||||
|
.tool_use_id(id)
|
||||||
|
.name(name)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ordinary_start(index: i32) -> AwsStreamEvent {
|
||||||
|
AwsStreamEvent::ContentBlockStart(
|
||||||
|
ContentBlockStartEvent::builder()
|
||||||
|
.content_block_index(index)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_delta(index: i32, delta: ContentBlockDelta) -> AwsStreamEvent {
|
||||||
|
AwsStreamEvent::ContentBlockDelta(
|
||||||
|
ContentBlockDeltaEvent::builder()
|
||||||
|
.content_block_index(index)
|
||||||
|
.delta(delta)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_stop(index: i32) -> AwsStreamEvent {
|
||||||
|
AwsStreamEvent::ContentBlockStop(
|
||||||
|
ContentBlockStopEvent::builder()
|
||||||
|
.content_block_index(index)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_stop(reason: AwsStopReason) -> AwsStreamEvent {
|
||||||
|
AwsStreamEvent::MessageStop(
|
||||||
|
MessageStopEvent::builder()
|
||||||
|
.stop_reason(reason)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata(usage: Usage) -> AwsStreamEvent {
|
||||||
|
AwsStreamEvent::Metadata(
|
||||||
|
ConverseStreamMetadataEvent::builder()
|
||||||
|
.usage(
|
||||||
|
TokenUsage::builder()
|
||||||
|
.input_tokens(usage.input_tokens as i32)
|
||||||
|
.output_tokens(usage.output_tokens as i32)
|
||||||
|
.total_tokens((usage.input_tokens + usage.output_tokens) as i32)
|
||||||
|
.cache_read_input_tokens(usage.cached_input_tokens as i32)
|
||||||
|
.cache_write_input_tokens(usage.cache_creation_input_tokens as i32)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_translator_accepts_ordinary_content_block_starts() {
|
||||||
|
let mut translator = BedrockStreamTranslator::default();
|
||||||
|
assert!(translator.translate(ordinary_start(0)).unwrap().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
translator
|
||||||
|
.translate(content_delta(
|
||||||
|
0,
|
||||||
|
ContentBlockDelta::Text("response".to_string()),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
vec![AgentEvent::TextDelta {
|
||||||
|
text: "response".to_string(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert!(translator.translate(content_stop(0)).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_translator_correlates_tools_by_content_index() {
|
||||||
|
let mut translator = BedrockStreamTranslator::default();
|
||||||
|
translator
|
||||||
|
.translate(tool_start(2, "call-2", "grep"))
|
||||||
|
.unwrap();
|
||||||
|
translator
|
||||||
|
.translate(tool_start(1, "call-1", "read_files"))
|
||||||
|
.unwrap();
|
||||||
|
translator
|
||||||
|
.translate(content_delta(
|
||||||
|
1,
|
||||||
|
ContentBlockDelta::ToolUse(
|
||||||
|
ToolUseBlockDelta::builder()
|
||||||
|
.input("{\"files\":[\"Cargo.toml\"]}")
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
translator
|
||||||
|
.translate(content_delta(
|
||||||
|
2,
|
||||||
|
ContentBlockDelta::ToolUse(
|
||||||
|
ToolUseBlockDelta::builder()
|
||||||
|
.input("{\"query\":\"ProviderRun\"}")
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let first = translator.translate(content_stop(1)).unwrap();
|
||||||
|
let second = translator.translate(content_stop(2)).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
first.as_slice(),
|
||||||
|
[AgentEvent::Tool {
|
||||||
|
event: ToolEvent::Proposed { call }
|
||||||
|
}] if call.id == "call-1"
|
||||||
|
&& call.name == "read_files"
|
||||||
|
&& call.arguments == json!({"files": ["Cargo.toml"]})
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
second.as_slice(),
|
||||||
|
[AgentEvent::Tool {
|
||||||
|
event: ToolEvent::Proposed { call }
|
||||||
|
}] if call.id == "call-2"
|
||||||
|
&& call.name == "grep"
|
||||||
|
&& call.arguments == json!({"query": "ProviderRun"})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_translator_defers_stop_until_usage_metadata_arrives() {
|
||||||
|
let mut translator = BedrockStreamTranslator::default();
|
||||||
|
assert!(translator
|
||||||
|
.translate(message_stop(AwsStopReason::EndTurn))
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
|
||||||
|
let expected_usage = Usage {
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 4,
|
||||||
|
cached_input_tokens: 7,
|
||||||
|
cache_creation_input_tokens: 3,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
translator
|
||||||
|
.translate(metadata(expected_usage.clone()))
|
||||||
|
.unwrap(),
|
||||||
|
vec![AgentEvent::UsageUpdated {
|
||||||
|
usage: expected_usage,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
translator.finish().unwrap(),
|
||||||
|
vec![AgentEvent::TurnStopped {
|
||||||
|
reason: StopReason::Completed,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_translator_preserves_reasoning_text_and_signature() {
|
||||||
|
let mut translator = BedrockStreamTranslator::default();
|
||||||
|
assert_eq!(
|
||||||
|
translator
|
||||||
|
.translate(content_delta(
|
||||||
|
0,
|
||||||
|
ContentBlockDelta::ReasoningContent(ReasoningContentBlockDelta::Text(
|
||||||
|
"inspect".to_string(),
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
vec![AgentEvent::ReasoningDelta {
|
||||||
|
text: "inspect".to_string(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
translator
|
||||||
|
.translate(content_delta(
|
||||||
|
0,
|
||||||
|
ContentBlockDelta::ReasoningContent(ReasoningContentBlockDelta::Signature(
|
||||||
|
"signature".to_string(),
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
translator.translate(content_stop(0)).unwrap(),
|
||||||
|
vec![AgentEvent::ReasoningCompleted {
|
||||||
|
text: "inspect".to_string(),
|
||||||
|
signature: Some("signature".to_string()),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_translator_rejects_invalid_tool_json() {
|
||||||
|
let mut translator = BedrockStreamTranslator::default();
|
||||||
|
translator
|
||||||
|
.translate(tool_start(0, "call", "read_files"))
|
||||||
|
.unwrap();
|
||||||
|
translator
|
||||||
|
.translate(content_delta(
|
||||||
|
0,
|
||||||
|
ContentBlockDelta::ToolUse(
|
||||||
|
ToolUseBlockDelta::builder()
|
||||||
|
.input("not-json")
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let error = translator.translate(content_stop(0)).unwrap_err();
|
||||||
|
assert_eq!(error.kind, AgentErrorKind::Protocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bedrock_stop_reasons_map_to_domain_reasons() {
|
||||||
|
assert_eq!(
|
||||||
|
map_stop_reason(&AwsStopReason::ToolUse),
|
||||||
|
StopReason::Completed
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
map_stop_reason(&AwsStopReason::MaxTokens),
|
||||||
|
StopReason::MaxTokens
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
map_stop_reason(&AwsStopReason::ModelContextWindowExceeded),
|
||||||
|
StopReason::ContextWindowExceeded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
map_stop_reason(&AwsStopReason::GuardrailIntervened),
|
||||||
|
StopReason::Refusal
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use warp_multi_agent_api as api;
|
|
||||||
|
|
||||||
use crate::ai::agent::api::ResponseStream;
|
|
||||||
use crate::ai::bedrock::client::{BedrockClient, BedrockClientConfig, BedrockError};
|
|
||||||
use crate::ai::bedrock::convert::ConversationMessage;
|
|
||||||
use crate::ai::bedrock::diagnostic::BedrockDiagnosticLogger;
|
|
||||||
use crate::ai::bedrock::request_translator;
|
|
||||||
|
|
||||||
pub struct TranslatorRequest {
|
|
||||||
pub config: BedrockClientConfig,
|
|
||||||
pub model_id: String,
|
|
||||||
pub root_task_id: Option<String>,
|
|
||||||
pub bedrock_message_history: Vec<ConversationMessage>,
|
|
||||||
pub bedrock_tool_result_archive: Vec<ConversationMessage>,
|
|
||||||
pub bedrock_progressive_summary: Option<String>,
|
|
||||||
pub bedrock_messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
|
|
||||||
/// Global rules (name, content) from the local CloudModel.
|
|
||||||
pub global_rules: Vec<(String, String)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
params: TranslatorRequest,
|
|
||||||
request: &mut api::Request,
|
|
||||||
) -> Result<ResponseStream, BedrockError> {
|
|
||||||
let config = params.config.with_external_fallbacks();
|
|
||||||
let cross_region_inference = config.cross_region_inference;
|
|
||||||
let bedrock = BedrockClient::from_config(config).await?;
|
|
||||||
|
|
||||||
let task_id = params.root_task_id.unwrap_or_else(|| {
|
|
||||||
request
|
|
||||||
.task_context
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|tc| tc.tasks.first())
|
|
||||||
.map(|t| t.id.clone())
|
|
||||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
|
|
||||||
});
|
|
||||||
|
|
||||||
let needs_create_task = request
|
|
||||||
.task_context
|
|
||||||
.as_ref()
|
|
||||||
.map(|tc| tc.tasks.is_empty())
|
|
||||||
.unwrap_or(true);
|
|
||||||
|
|
||||||
// Use the model from params (selected in UI or defaulted from ANTHROPIC_MODEL)
|
|
||||||
let mut model_id = params.model_id;
|
|
||||||
if model_id.is_empty() || model_id == "auto" {
|
|
||||||
// Fall back to default if nothing is set
|
|
||||||
model_id = "us.anthropic.claude-opus-4-6[1m]".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("[bedrock] Translator: model={model_id}, task_id={task_id}, needs_create_task={needs_create_task}");
|
|
||||||
|
|
||||||
let diagnostic_logger =
|
|
||||||
BedrockDiagnosticLogger::try_new(&model_id, "", "", &task_id).map(Arc::new);
|
|
||||||
|
|
||||||
if let Some(ref logger) = diagnostic_logger {
|
|
||||||
logger.log_protobuf_input(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
request_translator::inject_input_messages_into_task(request);
|
|
||||||
|
|
||||||
let new_input_messages = request_translator::extract_new_input_messages(request);
|
|
||||||
let new_input_count = new_input_messages.len();
|
|
||||||
|
|
||||||
let mut messages = Vec::new();
|
|
||||||
|
|
||||||
// Prepend progressive summary as the first message pair if present
|
|
||||||
if let Some(ref summary) = params.bedrock_progressive_summary {
|
|
||||||
use crate::ai::bedrock::convert::{MessageContent, MessageRole};
|
|
||||||
messages.push(ConversationMessage {
|
|
||||||
role: MessageRole::User,
|
|
||||||
content: MessageContent::Text(format!(
|
|
||||||
"<conversation-history-summary>\n{}\n</conversation-history-summary>\n\n\
|
|
||||||
The above summarizes earlier conversation history. The detailed messages below are the most recent exchanges.",
|
|
||||||
summary
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
messages.push(ConversationMessage {
|
|
||||||
role: MessageRole::Assistant,
|
|
||||||
content: MessageContent::Text(
|
|
||||||
"Understood, I have the prior context. Continuing with the recent conversation."
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let history_len = params.bedrock_message_history.len();
|
|
||||||
messages.extend(params.bedrock_message_history);
|
|
||||||
|
|
||||||
if !new_input_messages.is_empty() {
|
|
||||||
log::info!(
|
|
||||||
"[bedrock] Appending {} new input messages to history of {}",
|
|
||||||
new_input_messages.len(),
|
|
||||||
history_len
|
|
||||||
);
|
|
||||||
messages.extend(new_input_messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
for message in &mut messages {
|
|
||||||
message.truncate_tool_results_for_provider_request();
|
|
||||||
}
|
|
||||||
|
|
||||||
request_translator::sanitize_messages_for_bedrock(&mut messages);
|
|
||||||
|
|
||||||
let system_prompt = request_translator::extract_system_prompt(request, ¶ms.global_rules);
|
|
||||||
let tools = request_translator::extract_tools(request);
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[bedrock] Sending {} messages, system_prompt={}, progressive_summary={}, tools={}",
|
|
||||||
messages.len(),
|
|
||||||
system_prompt.is_some(),
|
|
||||||
params.bedrock_progressive_summary.is_some(),
|
|
||||||
tools.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
for (i, msg) in messages.iter().enumerate() {
|
|
||||||
let content_desc = describe_message_content(&msg.content);
|
|
||||||
log::info!(
|
|
||||||
"[bedrock] msg[{}]: role={:?}, content={}",
|
|
||||||
i,
|
|
||||||
msg.role,
|
|
||||||
content_desc
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let user_query_text = request_translator::extract_user_query_text(request);
|
|
||||||
|
|
||||||
let stream = bedrock
|
|
||||||
.converse_stream(
|
|
||||||
&model_id,
|
|
||||||
&task_id,
|
|
||||||
needs_create_task,
|
|
||||||
messages.clone(),
|
|
||||||
system_prompt,
|
|
||||||
None, // progressive summary is in messages array, not system prompt
|
|
||||||
tools,
|
|
||||||
64000,
|
|
||||||
None,
|
|
||||||
cross_region_inference,
|
|
||||||
user_query_text,
|
|
||||||
diagnostic_logger,
|
|
||||||
params.bedrock_messages_sent.clone(),
|
|
||||||
params.bedrock_tool_result_archive,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Ok(mut sent) = params.bedrock_messages_sent.lock() {
|
|
||||||
// Only persist the actual conversation history (history + new inputs), not the
|
|
||||||
// ephemeral prepended summary pair, so we don't duplicate the summary on every
|
|
||||||
// subsequent write-back. The summary is prepended at request time each turn.
|
|
||||||
let persistent_count = history_len + new_input_count;
|
|
||||||
if persistent_count > 0 && messages.len() >= persistent_count {
|
|
||||||
*sent = messages.split_off(messages.len() - persistent_count);
|
|
||||||
} else {
|
|
||||||
*sent = messages;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn describe_message_content(content: &crate::ai::bedrock::convert::MessageContent) -> String {
|
|
||||||
use crate::ai::bedrock::convert::{ContentPart, MessageContent};
|
|
||||||
match content {
|
|
||||||
MessageContent::Text(t) => format!("Text({}chars)", t.len()),
|
|
||||||
MessageContent::ToolUse {
|
|
||||||
tool_use_id, name, ..
|
|
||||||
} => format!("ToolUse(name={}, id={})", name, tool_use_id),
|
|
||||||
MessageContent::ToolResult {
|
|
||||||
tool_use_id,
|
|
||||||
is_error,
|
|
||||||
..
|
|
||||||
} => format!("ToolResult(id={}, is_error={})", tool_use_id, is_error),
|
|
||||||
MessageContent::MultiPart(parts) => {
|
|
||||||
let part_descs: Vec<String> = parts
|
|
||||||
.iter()
|
|
||||||
.map(|p| match p {
|
|
||||||
ContentPart::Text(t) => format!("Text({})", t.len()),
|
|
||||||
ContentPart::Image { data, mime_type } => {
|
|
||||||
format!("Image({mime_type},{}bytes)", data.len())
|
|
||||||
}
|
|
||||||
ContentPart::ToolUse {
|
|
||||||
name, tool_use_id, ..
|
|
||||||
} => format!("ToolUse({},{})", name, tool_use_id),
|
|
||||||
ContentPart::ToolResult { tool_use_id, .. } => {
|
|
||||||
format!("ToolResult({})", tool_use_id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
format!("MultiPart[{}]", part_descs.join(", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1220
-152
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ pub(super) mod use_computer;
|
|||||||
pub(super) mod wait_for_events;
|
pub(super) mod wait_for_events;
|
||||||
|
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -73,6 +74,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent};
|
pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent};
|
||||||
pub use start_agent::{
|
pub use start_agent::{
|
||||||
StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
|
StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
|
||||||
|
StartAgentWaitPolicy,
|
||||||
};
|
};
|
||||||
pub use suggest_new_conversation::NewConversationDecision;
|
pub use suggest_new_conversation::NewConversationDecision;
|
||||||
use suggest_new_conversation::SuggestNewConversationExecutor;
|
use suggest_new_conversation::SuggestNewConversationExecutor;
|
||||||
@@ -106,6 +108,27 @@ use crate::util::image::{
|
|||||||
use crate::util::openable_file_type::is_binary_file;
|
use crate::util::openable_file_type::is_binary_file;
|
||||||
use crate::BlocklistAIHistoryModel;
|
use crate::BlocklistAIHistoryModel;
|
||||||
|
|
||||||
|
const CHILD_AGENT_DELEGATION_DENIAL_REASON: &str =
|
||||||
|
"Child agents are leaf workers and cannot launch additional agents. Complete the assigned task directly or report the blocker to the lead agent.";
|
||||||
|
const CHILD_AGENT_LEAF_INSTRUCTIONS: &str = r#"You are a leaf worker launched by a lead agent.
|
||||||
|
- Complete the assigned task directly and stay within its stated scope.
|
||||||
|
- Do not launch, delegate to, or create additional agents.
|
||||||
|
- Report blockers and completion to the lead through the available coordination channel."#;
|
||||||
|
|
||||||
|
pub(super) fn child_agent_delegation_denial_reason(
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
ctx: &AppContext,
|
||||||
|
) -> Option<String> {
|
||||||
|
BlocklistAIHistoryModel::as_ref(ctx)
|
||||||
|
.conversation(&conversation_id)
|
||||||
|
.is_some_and(|conversation| conversation.is_child_agent_conversation())
|
||||||
|
.then(|| CHILD_AGENT_DELEGATION_DENIAL_REASON.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn compose_leaf_agent_prompt(task_prompt: &str) -> String {
|
||||||
|
format!("{CHILD_AGENT_LEAF_INSTRUCTIONS}\n\nAssigned task:\n{task_prompt}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Types of actions that can be executed in parallel.
|
/// Types of actions that can be executed in parallel.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(super) enum ParallelExecutionPolicy {
|
pub(super) enum ParallelExecutionPolicy {
|
||||||
@@ -209,12 +232,6 @@ pub enum NotExecutedReason {
|
|||||||
WaitingOnSharer,
|
WaitingOnSharer,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NotExecutedReason {
|
|
||||||
pub fn needs_confirmation(&self) -> bool {
|
|
||||||
matches!(self, Self::NeedsConfirmation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result type for `BlocklistAIActionExecutor::try_to_execute_action`.
|
/// Result type for `BlocklistAIActionExecutor::try_to_execute_action`.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(super) enum TryExecuteResult {
|
pub(super) enum TryExecuteResult {
|
||||||
@@ -229,9 +246,36 @@ pub(super) enum TryExecuteResult {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AsyncExecutingAction {
|
struct AsyncExecutingAction {
|
||||||
action: AIAgentAction,
|
action: AIAgentAction,
|
||||||
/// The conversation this action belongs to so cancellation and follow-up scheduling remain
|
}
|
||||||
/// scoped even when several conversations have async actions in flight.
|
|
||||||
conversation_id: AIConversationId,
|
type AsyncExecutingActionKey = (AIConversationId, AIAgentActionId);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AsyncExecutingActions(
|
||||||
|
std::collections::HashMap<AsyncExecutingActionKey, AsyncExecutingAction>,
|
||||||
|
);
|
||||||
|
|
||||||
|
impl AsyncExecutingActions {
|
||||||
|
fn insert(&mut self, conversation_id: AIConversationId, running: AsyncExecutingAction) {
|
||||||
|
self.0
|
||||||
|
.insert((conversation_id, running.action.id.clone()), running);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(
|
||||||
|
&self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
action_id: &AIAgentActionId,
|
||||||
|
) -> Option<&AsyncExecutingAction> {
|
||||||
|
self.0.get(&(conversation_id, action_id.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
action_id: &AIAgentActionId,
|
||||||
|
) -> Option<AsyncExecutingAction> {
|
||||||
|
self.0.remove(&(conversation_id, action_id.clone()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncExecutingAction {
|
impl AsyncExecutingAction {
|
||||||
@@ -270,10 +314,9 @@ pub struct BlocklistAIActionExecutor {
|
|||||||
send_message_executor: ModelHandle<SendMessageToAgentExecutor>,
|
send_message_executor: ModelHandle<SendMessageToAgentExecutor>,
|
||||||
ask_user_question_executor: ModelHandle<AskUserQuestionExecutor>,
|
ask_user_question_executor: ModelHandle<AskUserQuestionExecutor>,
|
||||||
wait_for_events_executor: ModelHandle<WaitForEventsExecutor>,
|
wait_for_events_executor: ModelHandle<WaitForEventsExecutor>,
|
||||||
/// The actions currently executing asynchronously, keyed by action ID.
|
/// The actions currently executing asynchronously, scoped by conversation and action ID.
|
||||||
/// We track them per action rather than as a single slot so multiple actions from the same
|
async_executing_actions: AsyncExecutingActions,
|
||||||
/// parallel phase can complete independently.
|
restored_action_ids: HashSet<AsyncExecutingActionKey>,
|
||||||
async_executing_actions: std::collections::HashMap<AIAgentActionId, AsyncExecutingAction>,
|
|
||||||
|
|
||||||
/// Reference to the terminal model for checking session sharing state.
|
/// Reference to the terminal model for checking session sharing state.
|
||||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||||
@@ -334,8 +377,9 @@ impl BlocklistAIActionExecutor {
|
|||||||
let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new(active_session.clone()));
|
let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new(active_session.clone()));
|
||||||
let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new());
|
let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new());
|
||||||
let start_agent_executor = ctx.add_model(StartAgentExecutor::new);
|
let start_agent_executor = ctx.add_model(StartAgentExecutor::new);
|
||||||
let run_agents_executor = ctx
|
let run_agents_executor = ctx.add_model(|ctx| {
|
||||||
.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id));
|
RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id, ctx)
|
||||||
|
});
|
||||||
let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new());
|
let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new());
|
||||||
let ask_user_question_executor =
|
let ask_user_question_executor =
|
||||||
ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
||||||
@@ -360,6 +404,7 @@ impl BlocklistAIActionExecutor {
|
|||||||
use_computer_executor,
|
use_computer_executor,
|
||||||
request_computer_use_executor,
|
request_computer_use_executor,
|
||||||
async_executing_actions: Default::default(),
|
async_executing_actions: Default::default(),
|
||||||
|
restored_action_ids: Default::default(),
|
||||||
terminal_model,
|
terminal_model,
|
||||||
read_skill_executor,
|
read_skill_executor,
|
||||||
fetch_conversation_executor,
|
fetch_conversation_executor,
|
||||||
@@ -371,12 +416,46 @@ impl BlocklistAIActionExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn async_executing_action(&self, action_id: &AIAgentActionId) -> Option<&AIAgentAction> {
|
pub fn async_executing_action(
|
||||||
|
&self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
action_id: &AIAgentActionId,
|
||||||
|
) -> Option<&AIAgentAction> {
|
||||||
self.async_executing_actions
|
self.async_executing_actions
|
||||||
.get(action_id)
|
.get(conversation_id, action_id)
|
||||||
.map(|running| &running.action)
|
.map(|running| &running.action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn mark_restored_actions(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
action_ids: &HashSet<AIAgentActionId>,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
self.restored_action_ids.extend(
|
||||||
|
action_ids
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|action_id| (conversation_id, action_id)),
|
||||||
|
);
|
||||||
|
self.run_agents_executor.update(ctx, |executor, _| {
|
||||||
|
executor.mark_recovery_actions(conversation_id, action_ids);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn has_running_ask_user_question(&self, conversation_id: AIConversationId) -> bool {
|
||||||
|
self.async_executing_actions
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.any(|((running_conversation_id, _), running)| {
|
||||||
|
*running_conversation_id == conversation_id
|
||||||
|
&& matches!(
|
||||||
|
running.action.action,
|
||||||
|
AIAgentActionType::AskUserQuestion { .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the action_id of any running WaitForEvents action for the
|
/// Returns the action_id of any running WaitForEvents action for the
|
||||||
/// given conversation. There is at most one (wait_for_events is
|
/// given conversation. There is at most one (wait_for_events is
|
||||||
/// documented as exclusive within a turn).
|
/// documented as exclusive within a turn).
|
||||||
@@ -384,10 +463,9 @@ impl BlocklistAIActionExecutor {
|
|||||||
&self,
|
&self,
|
||||||
conversation_id: AIConversationId,
|
conversation_id: AIConversationId,
|
||||||
) -> Option<AIAgentActionId> {
|
) -> Option<AIAgentActionId> {
|
||||||
self.async_executing_actions
|
self.async_executing_actions.0.iter().find_map(
|
||||||
.iter()
|
|((running_conversation_id, action_id), running)| {
|
||||||
.find_map(|(action_id, running)| {
|
if *running_conversation_id == conversation_id
|
||||||
if running.conversation_id == conversation_id
|
|
||||||
&& matches!(
|
&& matches!(
|
||||||
running.action.action,
|
running.action.action,
|
||||||
AIAgentActionType::WaitForEvents { .. }
|
AIAgentActionType::WaitForEvents { .. }
|
||||||
@@ -397,7 +475,8 @@ impl BlocklistAIActionExecutor {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shell_command_executor(&self) -> &ModelHandle<ShellCommandExecutor> {
|
pub fn shell_command_executor(&self) -> &ModelHandle<ShellCommandExecutor> {
|
||||||
@@ -602,8 +681,8 @@ impl BlocklistAIActionExecutor {
|
|||||||
is_user_initiated: bool,
|
is_user_initiated: bool,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) -> TryExecuteResult {
|
) -> TryExecuteResult {
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}",
|
"try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}",
|
||||||
action.id,
|
action.id,
|
||||||
std::mem::discriminant(&action.action),
|
std::mem::discriminant(&action.action),
|
||||||
is_user_initiated
|
is_user_initiated
|
||||||
@@ -611,7 +690,9 @@ impl BlocklistAIActionExecutor {
|
|||||||
|
|
||||||
// We should never actually execute actions in view-only mode.
|
// We should never actually execute actions in view-only mode.
|
||||||
if self.is_shared_session_viewer() {
|
if self.is_shared_session_viewer() {
|
||||||
log::info!("[tool-debug] try_to_execute_action: BLOCKED - shared session viewer mode");
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
|
"try_to_execute_action: BLOCKED - shared session viewer mode"
|
||||||
|
);
|
||||||
return TryExecuteResult::NotExecuted {
|
return TryExecuteResult::NotExecuted {
|
||||||
reason: NotExecutedReason::WaitingOnSharer,
|
reason: NotExecutedReason::WaitingOnSharer,
|
||||||
action: Box::new(action),
|
action: Box::new(action),
|
||||||
@@ -624,8 +705,8 @@ impl BlocklistAIActionExecutor {
|
|||||||
};
|
};
|
||||||
let can_auto_execute = self.should_autoexecute(input, ctx);
|
let can_auto_execute = self.should_autoexecute(input, ctx);
|
||||||
let is_agent_autonomous = AppExecutionMode::as_ref(ctx).is_autonomous();
|
let is_agent_autonomous = AppExecutionMode::as_ref(ctx).is_autonomous();
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}",
|
"try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}",
|
||||||
can_auto_execute,
|
can_auto_execute,
|
||||||
is_agent_autonomous
|
is_agent_autonomous
|
||||||
);
|
);
|
||||||
@@ -637,8 +718,8 @@ impl BlocklistAIActionExecutor {
|
|||||||
|| can_auto_execute
|
|| can_auto_execute
|
||||||
|| (is_agent_autonomous && action.action.is_request_command_output()));
|
|| (is_agent_autonomous && action.action.is_request_command_output()));
|
||||||
if needs_confirmation {
|
if needs_confirmation {
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}",
|
"try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}",
|
||||||
action.id
|
action.id
|
||||||
);
|
);
|
||||||
return TryExecuteResult::NotExecuted {
|
return TryExecuteResult::NotExecuted {
|
||||||
@@ -657,6 +738,7 @@ impl BlocklistAIActionExecutor {
|
|||||||
|
|
||||||
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
|
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
|
||||||
action_id: action_id.clone(),
|
action_id: action_id.clone(),
|
||||||
|
conversation_id,
|
||||||
});
|
});
|
||||||
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
|
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
|
||||||
result: Arc::new(AIAgentActionResult {
|
result: Arc::new(AIAgentActionResult {
|
||||||
@@ -672,11 +754,13 @@ impl BlocklistAIActionExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] try_to_execute_action: EXECUTING action_id={:?}, type={:?}",
|
"try_to_execute_action: EXECUTING action_id={:?}, type={:?}",
|
||||||
action.id,
|
action.id,
|
||||||
std::mem::discriminant(&action.action)
|
std::mem::discriminant(&action.action)
|
||||||
);
|
);
|
||||||
|
let action_key = (conversation_id, action.id.clone());
|
||||||
|
let is_restored = self.restored_action_ids.remove(&action_key);
|
||||||
let action_clone = action.clone();
|
let action_clone = action.clone();
|
||||||
let execution = match &action.action {
|
let execution = match &action.action {
|
||||||
AIAgentActionType::RequestCommandOutput { .. }
|
AIAgentActionType::RequestCommandOutput { .. }
|
||||||
@@ -828,8 +912,8 @@ impl BlocklistAIActionExecutor {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let action_id = action_clone.id.clone();
|
let action_id = action_clone.id.clone();
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] try_to_execute_action: execution result type={:?} for action_id={:?}",
|
"try_to_execute_action: execution result type={:?} for action_id={:?}",
|
||||||
match &execution {
|
match &execution {
|
||||||
AnyActionExecution::NotReady => "NotReady",
|
AnyActionExecution::NotReady => "NotReady",
|
||||||
AnyActionExecution::InvalidAction => "InvalidAction",
|
AnyActionExecution::InvalidAction => "InvalidAction",
|
||||||
@@ -840,8 +924,8 @@ impl BlocklistAIActionExecutor {
|
|||||||
);
|
);
|
||||||
match execution {
|
match execution {
|
||||||
AnyActionExecution::NotReady => {
|
AnyActionExecution::NotReady => {
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] try_to_execute_action: NOT READY - action_id={:?}",
|
"try_to_execute_action: NOT READY - action_id={:?}",
|
||||||
action_id
|
action_id
|
||||||
);
|
);
|
||||||
TryExecuteResult::NotExecuted {
|
TryExecuteResult::NotExecuted {
|
||||||
@@ -851,7 +935,7 @@ impl BlocklistAIActionExecutor {
|
|||||||
}
|
}
|
||||||
AnyActionExecution::InvalidAction => {
|
AnyActionExecution::InvalidAction => {
|
||||||
log::error!(
|
log::error!(
|
||||||
"[tool-debug] try_to_execute_action: INVALID ACTION - action_id={:?}",
|
"try_to_execute_action: invalid action, action_id={:?}",
|
||||||
action_id
|
action_id
|
||||||
);
|
);
|
||||||
debug_assert!(false, "Tried to execute AIAgentAction with wrong executor.");
|
debug_assert!(false, "Tried to execute AIAgentAction with wrong executor.");
|
||||||
@@ -865,24 +949,32 @@ impl BlocklistAIActionExecutor {
|
|||||||
on_complete,
|
on_complete,
|
||||||
} => {
|
} => {
|
||||||
self.async_executing_actions.insert(
|
self.async_executing_actions.insert(
|
||||||
action_id.clone(),
|
conversation_id,
|
||||||
AsyncExecutingAction {
|
AsyncExecutingAction {
|
||||||
action: action_clone,
|
action: action_clone,
|
||||||
conversation_id,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
|
if !is_restored {
|
||||||
action_id: action_id.clone(),
|
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
|
||||||
});
|
action_id: action_id.clone(),
|
||||||
log::info!("[tool-debug] try_to_execute_action: spawning ASYNC execution for action_id={:?}", action_id);
|
conversation_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
|
"try_to_execute_action: spawning ASYNC execution for action_id={:?}",
|
||||||
|
action_id
|
||||||
|
);
|
||||||
ctx.spawn(execute_future, move |me, result, ctx| {
|
ctx.spawn(execute_future, move |me, result, ctx| {
|
||||||
let Some(running) = me.async_executing_actions.remove(&action_id) else {
|
let Some(running) = me
|
||||||
log::warn!("[tool-debug] try_to_execute_action: async action completed but not found in executing map, action_id={:?}", action_id);
|
.async_executing_actions
|
||||||
|
.remove(conversation_id, &action_id)
|
||||||
|
else {
|
||||||
|
log::warn!("try_to_execute_action: async action completed but not found in executing map, conversation_id={conversation_id}, action_id={action_id:?}");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let result = on_complete(result, ctx);
|
let result = on_complete(result, ctx);
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}",
|
"try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}",
|
||||||
action_id,
|
action_id,
|
||||||
std::mem::discriminant(&result)
|
std::mem::discriminant(&result)
|
||||||
);
|
);
|
||||||
@@ -892,16 +984,19 @@ impl BlocklistAIActionExecutor {
|
|||||||
task_id: running.action.task_id,
|
task_id: running.action.task_id,
|
||||||
result,
|
result,
|
||||||
}),
|
}),
|
||||||
conversation_id: running.conversation_id,
|
conversation_id,
|
||||||
cancellation_reason: None,
|
cancellation_reason: None,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
TryExecuteResult::ExecutedAsync
|
TryExecuteResult::ExecutedAsync
|
||||||
}
|
}
|
||||||
AnyActionExecution::Sync(action_result) => {
|
AnyActionExecution::Sync(action_result) => {
|
||||||
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
|
if !is_restored {
|
||||||
action_id: action_id.clone(),
|
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
|
||||||
});
|
action_id: action_id.clone(),
|
||||||
|
conversation_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
|
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
|
||||||
result: Arc::new(AIAgentActionResult {
|
result: Arc::new(AIAgentActionResult {
|
||||||
id: action_id,
|
id: action_id,
|
||||||
@@ -933,6 +1028,7 @@ impl BlocklistAIActionExecutor {
|
|||||||
|
|
||||||
pub fn cancel_running_async_action(
|
pub fn cancel_running_async_action(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
action_id: &AIAgentActionId,
|
action_id: &AIAgentActionId,
|
||||||
reason: Option<CancellationReason>,
|
reason: Option<CancellationReason>,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
@@ -941,13 +1037,42 @@ impl BlocklistAIActionExecutor {
|
|||||||
if self.is_shared_session_viewer() {
|
if self.is_shared_session_viewer() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(running) = self.async_executing_actions.remove(action_id) {
|
if self
|
||||||
|
.async_executing_actions
|
||||||
|
.get(conversation_id, action_id)
|
||||||
|
.is_some_and(|running| {
|
||||||
|
matches!(
|
||||||
|
running.action.action,
|
||||||
|
AIAgentActionType::RequestCommandOutput { .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
let termination_requested = self.shell_command_executor.update(ctx, |executor, ctx| {
|
||||||
|
executor.cancel_execution(action_id, ctx)
|
||||||
|
});
|
||||||
|
if termination_requested {
|
||||||
|
// Keep the action in flight until block completion proves the process stopped.
|
||||||
|
// Its normal async completion will report the actual terminal exit status.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(running) = self
|
||||||
|
.async_executing_actions
|
||||||
|
.remove(conversation_id, action_id)
|
||||||
|
{
|
||||||
let action_kind = AIAgentActionTypeDiscriminants::from(&running.action.action);
|
let action_kind = AIAgentActionTypeDiscriminants::from(&running.action.action);
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}, backtrace=\n{}",
|
"Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}"
|
||||||
std::backtrace::Backtrace::force_capture()
|
|
||||||
);
|
);
|
||||||
if running.is_shell_command_action() {
|
if let Some(backtrace) = crate::ai::tool_diagnostics::capture_backtrace() {
|
||||||
|
log::debug!("Running action cancellation backtrace:\n{backtrace}");
|
||||||
|
}
|
||||||
|
if running.is_shell_command_action()
|
||||||
|
&& !matches!(
|
||||||
|
running.action.action,
|
||||||
|
AIAgentActionType::RequestCommandOutput { .. }
|
||||||
|
)
|
||||||
|
{
|
||||||
self.shell_command_executor.update(ctx, |executor, ctx| {
|
self.shell_command_executor.update(ctx, |executor, ctx| {
|
||||||
executor.cancel_execution(&running.action.id, ctx);
|
executor.cancel_execution(&running.action.id, ctx);
|
||||||
});
|
});
|
||||||
@@ -957,7 +1082,11 @@ impl BlocklistAIActionExecutor {
|
|||||||
});
|
});
|
||||||
} else if matches!(running.action.action, AIAgentActionType::RunAgents(..)) {
|
} else if matches!(running.action.action, AIAgentActionType::RunAgents(..)) {
|
||||||
self.run_agents_executor.update(ctx, |executor, ctx| {
|
self.run_agents_executor.update(ctx, |executor, ctx| {
|
||||||
executor.cancel_execution(&running.action.id, ctx);
|
executor.cancel_execution(conversation_id, &running.action.id, ctx);
|
||||||
|
});
|
||||||
|
} else if matches!(running.action.action, AIAgentActionType::StartAgent { .. }) {
|
||||||
|
self.start_agent_executor.update(ctx, |executor, _| {
|
||||||
|
executor.cancel_execution(conversation_id, &running.action.id);
|
||||||
});
|
});
|
||||||
} else if let AIAgentActionType::WaitForEvents { tool_call_id, .. } =
|
} else if let AIAgentActionType::WaitForEvents { tool_call_id, .. } =
|
||||||
&running.action.action
|
&running.action.action
|
||||||
@@ -975,7 +1104,7 @@ impl BlocklistAIActionExecutor {
|
|||||||
task_id: running.action.task_id,
|
task_id: running.action.task_id,
|
||||||
result: running.action.action.cancelled_result(),
|
result: running.action.action.cancelled_result(),
|
||||||
}),
|
}),
|
||||||
conversation_id: running.conversation_id,
|
conversation_id,
|
||||||
cancellation_reason: reason,
|
cancellation_reason: reason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -989,18 +1118,23 @@ impl BlocklistAIActionExecutor {
|
|||||||
) {
|
) {
|
||||||
let action_ids = self
|
let action_ids = self
|
||||||
.async_executing_actions
|
.async_executing_actions
|
||||||
|
.0
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(action_id, running)| {
|
.filter_map(|((running_conversation_id, action_id), _)| {
|
||||||
(running.conversation_id == conversation_id).then_some(action_id.clone())
|
(*running_conversation_id == conversation_id).then_some(action_id.clone())
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
for action_id in action_ids {
|
for action_id in action_ids {
|
||||||
self.cancel_running_async_action(&action_id, reason, ctx);
|
self.cancel_running_async_action(conversation_id, &action_id, reason, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_autoexecute(&self, input: ExecuteActionInput, ctx: &mut ModelContext<Self>) -> bool {
|
fn should_autoexecute(&self, input: ExecuteActionInput, ctx: &mut ModelContext<Self>) -> bool {
|
||||||
if cfg!(feature = "bedrock_smoke_test") {
|
if self
|
||||||
|
.restored_action_ids
|
||||||
|
.contains(&(input.conversation_id, input.action.id.clone()))
|
||||||
|
|| cfg!(feature = "bedrock_smoke_test")
|
||||||
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
match input.action.action {
|
match input.action.action {
|
||||||
@@ -1109,9 +1243,10 @@ impl Entity for BlocklistAIActionExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub enum BlocklistAIActionExecutorEvent {
|
pub enum BlocklistAIActionExecutorEvent {
|
||||||
/// Emitted when an action is execution starts.
|
/// Emitted when an action begins execution.
|
||||||
ExecutingAction {
|
ExecutingAction {
|
||||||
action_id: AIAgentActionId,
|
action_id: AIAgentActionId,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Emitted when an action has finished.
|
/// Emitted when an action has finished.
|
||||||
@@ -1442,6 +1577,95 @@ async fn read_file_as_binary(file_path: &std::path::Path) -> Result<Vec<u8>, Fil
|
|||||||
async_fs::read(file_path).await.map_err(FileLoadError::from)
|
async_fs::read(file_path).await.map_err(FileLoadError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod async_executing_action_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::ai::agent::task::TaskId;
|
||||||
|
|
||||||
|
fn action(id: &str, task_id: &str) -> AIAgentAction {
|
||||||
|
AIAgentAction {
|
||||||
|
id: AIAgentActionId::from(id.to_owned()),
|
||||||
|
action: AIAgentActionType::InitProject,
|
||||||
|
task_id: TaskId::new(task_id.to_owned()),
|
||||||
|
requires_result: true,
|
||||||
|
tool_name: Some("init_project".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_action_ids_can_execute_concurrently_in_different_conversations() {
|
||||||
|
let first_conversation = AIConversationId::new();
|
||||||
|
let second_conversation = AIConversationId::new();
|
||||||
|
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
|
||||||
|
let mut running = AsyncExecutingActions::default();
|
||||||
|
|
||||||
|
running.insert(
|
||||||
|
first_conversation,
|
||||||
|
AsyncExecutingAction {
|
||||||
|
action: action("duplicate", "first-task"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
running.insert(
|
||||||
|
second_conversation,
|
||||||
|
AsyncExecutingAction {
|
||||||
|
action: action("duplicate", "second-task"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(running.0.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
running
|
||||||
|
.get(first_conversation, &duplicate_id)
|
||||||
|
.unwrap()
|
||||||
|
.action
|
||||||
|
.task_id,
|
||||||
|
TaskId::new("first-task".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
running
|
||||||
|
.get(second_conversation, &duplicate_id)
|
||||||
|
.unwrap()
|
||||||
|
.action
|
||||||
|
.task_id,
|
||||||
|
TaskId::new("second-task".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_action_completion_and_cancellation_remove_only_the_matching_conversation() {
|
||||||
|
let first_conversation = AIConversationId::new();
|
||||||
|
let second_conversation = AIConversationId::new();
|
||||||
|
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
|
||||||
|
let mut running = AsyncExecutingActions::default();
|
||||||
|
running.insert(
|
||||||
|
first_conversation,
|
||||||
|
AsyncExecutingAction {
|
||||||
|
action: action("duplicate", "first-task"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
running.insert(
|
||||||
|
second_conversation,
|
||||||
|
AsyncExecutingAction {
|
||||||
|
action: action("duplicate", "second-task"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let completed = running.remove(first_conversation, &duplicate_id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
completed.action.task_id,
|
||||||
|
TaskId::new("first-task".to_owned())
|
||||||
|
);
|
||||||
|
assert!(running.get(second_conversation, &duplicate_id).is_some());
|
||||||
|
|
||||||
|
let cancelled = running.remove(second_conversation, &duplicate_id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cancelled.action.task_id,
|
||||||
|
TaskId::new("second-task".to_owned())
|
||||||
|
);
|
||||||
|
assert!(running.0.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "local_fs"))]
|
#[cfg(all(test, feature = "local_fs"))]
|
||||||
#[path = "execute_tests.rs"]
|
#[path = "execute_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ fn initialize_ask_user_question_test(
|
|||||||
app.add_singleton_model(TeamTesterStatus::mock);
|
app.add_singleton_model(TeamTesterStatus::mock);
|
||||||
app.add_singleton_model(UpdateManager::mock);
|
app.add_singleton_model(UpdateManager::mock);
|
||||||
app.add_singleton_model(CloudModel::mock);
|
app.add_singleton_model(CloudModel::mock);
|
||||||
|
app.add_singleton_model(|ctx| {
|
||||||
|
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
|
||||||
|
});
|
||||||
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
|
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
|
||||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||||
let profiles = app.add_singleton_model(|ctx| {
|
let profiles = app.add_singleton_model(|ctx| {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ impl CallMCPToolExecutor {
|
|||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
{
|
{
|
||||||
log::info!("[tool-debug] CallMCPToolExecutor::execute called");
|
crate::ai::tool_diagnostics::tool_debug!("CallMCPToolExecutor::execute called");
|
||||||
let server_output_id = get_server_output_id(input.conversation_id, ctx);
|
let server_output_id = get_server_output_id(input.conversation_id, ctx);
|
||||||
let AIAgentAction {
|
let AIAgentAction {
|
||||||
action:
|
action:
|
||||||
@@ -97,21 +97,21 @@ impl CallMCPToolExecutor {
|
|||||||
..
|
..
|
||||||
} = input.action
|
} = input.action
|
||||||
else {
|
else {
|
||||||
log::error!("[tool-debug] CallMCPToolExecutor::execute: action type mismatch!");
|
log::error!("CallMCPToolExecutor::execute: action type mismatch");
|
||||||
return ActionExecution::InvalidAction;
|
return ActionExecution::InvalidAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
let name_owned = name.to_owned();
|
let name_owned = name.to_owned();
|
||||||
let name_clone = name_owned.clone();
|
let name_clone = name_owned.clone();
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
|
"CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
|
||||||
name,
|
name,
|
||||||
server_id,
|
server_id,
|
||||||
serde_json::to_string(input).unwrap_or_else(|_| "<serialize error>".to_string())
|
serde_json::to_string(input).unwrap_or_else(|_| "<serialize error>".to_string())
|
||||||
);
|
);
|
||||||
|
|
||||||
let serde_json::Value::Object(mut arguments) = input.clone() else {
|
let serde_json::Value::Object(mut arguments) = input.clone() else {
|
||||||
log::error!("[tool-debug] CallMCPToolExecutor: input is not an object!");
|
log::error!("CallMCPToolExecutor: input is not an object");
|
||||||
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
|
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
|
||||||
CallMCPToolResult::Error("MCP server tool input not an object".to_owned()),
|
CallMCPToolResult::Error("MCP server tool input not an object".to_owned()),
|
||||||
));
|
));
|
||||||
@@ -143,15 +143,15 @@ impl CallMCPToolExecutor {
|
|||||||
|
|
||||||
let Some(reconnecting_peer) = templatable_peer else {
|
let Some(reconnecting_peer) = templatable_peer else {
|
||||||
log::error!(
|
log::error!(
|
||||||
"[tool-debug] CallMCPToolExecutor: MCP server for tool '{}' NOT FOUND",
|
"CallMCPToolExecutor: MCP server for tool '{}' not found",
|
||||||
name_owned
|
name_owned
|
||||||
);
|
);
|
||||||
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
|
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
|
||||||
CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
|
CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] CallMCPToolExecutor: found MCP server peer for tool '{}'",
|
"CallMCPToolExecutor: found MCP server peer for tool '{}'",
|
||||||
name_owned
|
name_owned
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -314,8 +314,8 @@ fn handle_call_tool_result(
|
|||||||
tool_name: String,
|
tool_name: String,
|
||||||
ctx: &galaxyui::AppContext,
|
ctx: &galaxyui::AppContext,
|
||||||
) -> AIAgentActionResultType {
|
) -> AIAgentActionResultType {
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] handle_call_tool_result: tool_name={}, is_ok={}",
|
"handle_call_tool_result: tool_name={}, is_ok={}",
|
||||||
tool_name,
|
tool_name,
|
||||||
res.is_ok()
|
res.is_ok()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ impl FileGlobExecutor {
|
|||||||
else {
|
else {
|
||||||
return ActionExecution::InvalidAction;
|
return ActionExecution::InvalidAction;
|
||||||
};
|
};
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] FileGlobExecutor::execute: patterns={:?}, path={:?}",
|
"FileGlobExecutor::execute: patterns={:?}, path={:?}",
|
||||||
patterns,
|
patterns,
|
||||||
path
|
path
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -237,8 +237,8 @@ impl GrepExecutor {
|
|||||||
else {
|
else {
|
||||||
return ActionExecution::InvalidAction;
|
return ActionExecution::InvalidAction;
|
||||||
};
|
};
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] GrepExecutor::execute: queries={:?}, path={:?}",
|
"GrepExecutor::execute: queries={:?}, path={:?}",
|
||||||
queries,
|
queries,
|
||||||
path
|
path
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ impl ReadFilesExecutor {
|
|||||||
else {
|
else {
|
||||||
return ActionExecution::InvalidAction;
|
return ActionExecution::InvalidAction;
|
||||||
};
|
};
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] ReadFilesExecutor::execute: {} files requested",
|
"ReadFilesExecutor::execute: {} files requested",
|
||||||
locations.len()
|
locations.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,34 @@ use crate::terminal::model::session::SessionType;
|
|||||||
use crate::{safe_warn, BlocklistAIHistoryModel};
|
use crate::{safe_warn, BlocklistAIHistoryModel};
|
||||||
const APPLY_DIFF_RESULT_CONTEXT_LINES: usize = 10;
|
const APPLY_DIFF_RESULT_CONTEXT_LINES: usize = 10;
|
||||||
|
|
||||||
|
type AppliedDiffs = (Vec<FileDiff>, DiffSessionType);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct PendingAppliedDiffs {
|
||||||
|
by_action: HashMap<AIAgentActionId, AppliedDiffs>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingAppliedDiffs {
|
||||||
|
fn buffer(
|
||||||
|
&mut self,
|
||||||
|
action_id: AIAgentActionId,
|
||||||
|
diffs: Vec<FileDiff>,
|
||||||
|
diff_session_type: DiffSessionType,
|
||||||
|
) {
|
||||||
|
self.by_action.insert(action_id, (diffs, diff_session_type));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(&mut self, action_id: &AIAgentActionId) -> Option<AppliedDiffs> {
|
||||||
|
self.by_action.remove(action_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct RequestFileEditsExecutor {
|
pub struct RequestFileEditsExecutor {
|
||||||
active_session: ModelHandle<ActiveSession>,
|
active_session: ModelHandle<ActiveSession>,
|
||||||
apply_diff_model: ModelHandle<ApplyDiffModel>,
|
apply_diff_model: ModelHandle<ApplyDiffModel>,
|
||||||
diff_views: HashMap<AIAgentActionId, ViewHandle<CodeDiffView>>,
|
diff_views: HashMap<AIAgentActionId, ViewHandle<CodeDiffView>>,
|
||||||
|
/// Successfully applied diffs that completed before their view was registered.
|
||||||
|
pending_applied_diffs: PendingAppliedDiffs,
|
||||||
/// Set of action IDs where diff application failed.
|
/// Set of action IDs where diff application failed.
|
||||||
diff_application_failures: HashMap<AIAgentActionId, Vec1<DiffApplicationError>>,
|
diff_application_failures: HashMap<AIAgentActionId, Vec1<DiffApplicationError>>,
|
||||||
terminal_view_id: EntityId,
|
terminal_view_id: EntityId,
|
||||||
@@ -62,6 +86,7 @@ impl RequestFileEditsExecutor {
|
|||||||
active_session,
|
active_session,
|
||||||
apply_diff_model,
|
apply_diff_model,
|
||||||
diff_views: HashMap::new(),
|
diff_views: HashMap::new(),
|
||||||
|
pending_applied_diffs: PendingAppliedDiffs::default(),
|
||||||
diff_application_failures: HashMap::new(),
|
diff_application_failures: HashMap::new(),
|
||||||
terminal_view_id,
|
terminal_view_id,
|
||||||
}
|
}
|
||||||
@@ -117,15 +142,18 @@ impl RequestFileEditsExecutor {
|
|||||||
.is_allowed()
|
.is_allowed()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers a diff view to handle a RequestFileEdits action.
|
/// Registers a diff view to handle a RequestFileEdits action and applies any diffs that
|
||||||
/// Note this MUST be called before `execute` or `preprocess_action` is invoked in
|
/// finished preprocessing before the UI observed the action.
|
||||||
/// order for the necessary state to be set to handle the action.
|
|
||||||
pub fn register_requested_edits(
|
pub fn register_requested_edits(
|
||||||
&mut self,
|
&mut self,
|
||||||
action_id: &AIAgentActionId,
|
action_id: &AIAgentActionId,
|
||||||
view: &ViewHandle<CodeDiffView>,
|
view: &ViewHandle<CodeDiffView>,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
self.diff_views.insert(action_id.clone(), view.clone());
|
self.diff_views.insert(action_id.clone(), view.clone());
|
||||||
|
if let Some((diffs, diff_session_type)) = self.pending_applied_diffs.take(action_id) {
|
||||||
|
Self::apply_diffs_to_view(view, diffs, diff_session_type, ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn execute(
|
pub(super) fn execute(
|
||||||
@@ -145,14 +173,14 @@ impl RequestFileEditsExecutor {
|
|||||||
else {
|
else {
|
||||||
return ActionExecution::InvalidAction;
|
return ActionExecution::InvalidAction;
|
||||||
};
|
};
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] RequestFileEditsExecutor::execute: action_id={:?}",
|
"RequestFileEditsExecutor::execute: action_id={:?}",
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
|
|
||||||
let Some(diff_view) = self.diff_views.get(id) else {
|
let Some(diff_view) = self.diff_views.get(id) else {
|
||||||
log::warn!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] RequestFileEditsExecutor: no diff view found for action_id={:?}",
|
"RequestFileEditsExecutor: no diff view found for action_id={:?}",
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
return ActionExecution::NotReady;
|
return ActionExecution::NotReady;
|
||||||
@@ -322,23 +350,43 @@ impl RequestFileEditsExecutor {
|
|||||||
tx: oneshot::Sender<()>,
|
tx: oneshot::Sender<()>,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
tx.send(()).ok();
|
match applied_diffs {
|
||||||
|
Ok(applied_diffs) if !applied_diffs.is_empty() => {
|
||||||
|
let current_working_directory = self
|
||||||
|
.active_session
|
||||||
|
.as_ref(ctx)
|
||||||
|
.current_working_directory()
|
||||||
|
.cloned();
|
||||||
|
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
|
||||||
|
let diffs = applied_diffs
|
||||||
|
.into_iter()
|
||||||
|
.map(|diff| {
|
||||||
|
let path = host_native_absolute_path(
|
||||||
|
diff.file_name.as_str(),
|
||||||
|
&shell_launch_data,
|
||||||
|
¤t_working_directory,
|
||||||
|
);
|
||||||
|
FileDiff::new(diff.original_content, path, diff.diff_type)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) {
|
||||||
|
Some(SessionType::WarpifiedRemote {
|
||||||
|
host_id: Some(host_id),
|
||||||
|
}) => DiffSessionType::Remote(host_id.clone()),
|
||||||
|
_ => DiffSessionType::Local,
|
||||||
|
};
|
||||||
|
|
||||||
let Some(diff_view) = self.diff_views.get(&id) else {
|
if let Some(diff_view) = self.diff_views.get(&id).cloned() {
|
||||||
log::warn!(
|
Self::apply_diffs_to_view(&diff_view, diffs, diff_session_type, ctx);
|
||||||
"Tried to apply diffs for a RequestFileEdits action without a corresponding diff view"
|
} else {
|
||||||
);
|
self.pending_applied_diffs
|
||||||
return;
|
.buffer(id, diffs, diff_session_type);
|
||||||
};
|
}
|
||||||
|
}
|
||||||
let applied_diffs = match applied_diffs {
|
|
||||||
Ok(diffs) if !diffs.is_empty() => diffs,
|
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// We didn't generate any diffs--consider this a failure.
|
|
||||||
log::warn!("No diffs generated");
|
log::warn!("No diffs generated");
|
||||||
self.diff_application_failures
|
self.diff_application_failures
|
||||||
.insert(id, vec1![DiffApplicationError::EmptyDiff]);
|
.insert(id, vec1![DiffApplicationError::EmptyDiff]);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
safe_warn!(
|
safe_warn!(
|
||||||
@@ -346,38 +394,18 @@ impl RequestFileEditsExecutor {
|
|||||||
full: ("Failed to generate diffs {err:?}")
|
full: ("Failed to generate diffs {err:?}")
|
||||||
);
|
);
|
||||||
self.diff_application_failures.insert(id, err);
|
self.diff_application_failures.insert(id, err);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let current_working_directory = self
|
|
||||||
.active_session
|
|
||||||
.as_ref(ctx)
|
|
||||||
.current_working_directory()
|
|
||||||
.cloned();
|
|
||||||
|
|
||||||
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
|
|
||||||
|
|
||||||
let mut diffs = Vec::with_capacity(applied_diffs.len());
|
|
||||||
for diff in applied_diffs {
|
|
||||||
let path = host_native_absolute_path(
|
|
||||||
diff.file_name.as_str(),
|
|
||||||
&shell_launch_data,
|
|
||||||
¤t_working_directory,
|
|
||||||
);
|
|
||||||
let file_diff = FileDiff::new(diff.original_content, path, diff.diff_type);
|
|
||||||
diffs.push(file_diff);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the session type on the diff view so save/delete/create routes
|
tx.send(()).ok();
|
||||||
// through the correct FileModel backend.
|
}
|
||||||
let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) {
|
|
||||||
Some(SessionType::WarpifiedRemote {
|
|
||||||
host_id: Some(host_id),
|
|
||||||
}) => DiffSessionType::Remote(host_id.clone()),
|
|
||||||
_ => DiffSessionType::Local,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
fn apply_diffs_to_view(
|
||||||
|
diff_view: &ViewHandle<CodeDiffView>,
|
||||||
|
diffs: Vec<FileDiff>,
|
||||||
|
diff_session_type: DiffSessionType,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
diff_view.update(ctx, |diff_view, ctx| {
|
diff_view.update(ctx, |diff_view, ctx| {
|
||||||
diff_view.set_diff_session_type(diff_session_type);
|
diff_view.set_diff_session_type(diff_session_type);
|
||||||
diff_view.set_candidate_diffs(diffs, ctx);
|
diff_view.set_candidate_diffs(diffs, ctx);
|
||||||
|
|||||||
@@ -2,8 +2,36 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use ai::agent::action_result::AnyFileContent;
|
use ai::agent::action_result::AnyFileContent;
|
||||||
use ai::agent::FileLocations;
|
use ai::agent::FileLocations;
|
||||||
|
use ai::diff_validation::DiffType;
|
||||||
|
|
||||||
use super::updated_file_contexts_from_editor_buffers;
|
use super::{
|
||||||
|
updated_file_contexts_from_editor_buffers, AIAgentActionId, DiffSessionType, FileDiff,
|
||||||
|
PendingAppliedDiffs,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applied_diffs_survive_until_delayed_view_registration() {
|
||||||
|
let action_id = AIAgentActionId::from("file-edit".to_string());
|
||||||
|
let mut pending = PendingAppliedDiffs::default();
|
||||||
|
pending.buffer(
|
||||||
|
action_id.clone(),
|
||||||
|
vec![FileDiff::new(
|
||||||
|
"before".to_string(),
|
||||||
|
"/workspace/src/main.rs".to_string(),
|
||||||
|
DiffType::update(vec![], None),
|
||||||
|
)],
|
||||||
|
DiffSessionType::Local,
|
||||||
|
);
|
||||||
|
|
||||||
|
let (diffs, session_type) = pending
|
||||||
|
.take(&action_id)
|
||||||
|
.expect("buffered diffs should remain available for registration");
|
||||||
|
assert_eq!(diffs.len(), 1);
|
||||||
|
assert_eq!(diffs[0].base.content, "before");
|
||||||
|
assert_eq!(diffs[0].base.file_path, "/workspace/src/main.rs");
|
||||||
|
assert!(matches!(session_type, DiffSessionType::Local));
|
||||||
|
assert!(pending.take(&action_id).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn updated_file_contexts_from_editor_buffers_returns_changed_lines_with_context() {
|
fn updated_file_contexts_from_editor_buffers_returns_changed_lines_with_context() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Fans out per-child via [`super::start_agent::StartAgentExecutor::dispatch`]
|
//! Fans out per-child via [`super::start_agent::StartAgentExecutor::dispatch`]
|
||||||
//! and aggregates the outcomes into a single `RunAgentsResult`.
|
//! and aggregates the outcomes into a single `RunAgentsResult`.
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
|
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
|
||||||
@@ -12,15 +12,21 @@ use ai::agent::action_result::{
|
|||||||
};
|
};
|
||||||
use ai::agent::orchestration_config::OrchestrationConfig;
|
use ai::agent::orchestration_config::OrchestrationConfig;
|
||||||
use ai::skills::SkillReference;
|
use ai::skills::SkillReference;
|
||||||
use futures::future::BoxFuture;
|
use futures::future::{join_all, BoxFuture};
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
use galaxy_core::execution_mode::AppExecutionMode;
|
use galaxy_core::execution_mode::AppExecutionMode;
|
||||||
use settings::Setting;
|
use settings::Setting;
|
||||||
use warp_cli::agent::Harness;
|
use warp_cli::agent::Harness;
|
||||||
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||||
|
|
||||||
use super::start_agent::{StartAgentExecutor, StartAgentOutcome};
|
use super::start_agent::{
|
||||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
StartAgentDispatch, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome,
|
||||||
|
StartAgentRequestId, StartAgentWaitPolicy,
|
||||||
|
};
|
||||||
|
use super::{
|
||||||
|
child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput,
|
||||||
|
PreprocessActionInput,
|
||||||
|
};
|
||||||
use crate::ai::agent::conversation::AIConversationId;
|
use crate::ai::agent::conversation::AIConversationId;
|
||||||
use crate::ai::agent::{
|
use crate::ai::agent::{
|
||||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentInput,
|
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentInput,
|
||||||
@@ -34,6 +40,8 @@ use crate::ai::document::plan_publication::{
|
|||||||
prepare_plan_publications, wait_for_plan_publications,
|
prepare_plan_publications, wait_for_plan_publications,
|
||||||
};
|
};
|
||||||
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
|
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
|
||||||
|
|
||||||
/// Per-child spawn timeout. If a child agent doesn't report back within
|
/// Per-child spawn timeout. If a child agent doesn't report back within
|
||||||
/// this window (e.g. binary not found, server error), the slot is failed
|
/// this window (e.g. binary not found, server error), the slot is failed
|
||||||
@@ -60,7 +68,8 @@ struct ExistingLaunchedAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct RunAgentsExecutor {
|
pub struct RunAgentsExecutor {
|
||||||
pending: HashMap<AIAgentActionId, PendingRunAgents>,
|
pending: HashMap<(AIConversationId, AIAgentActionId), PendingRunAgents>,
|
||||||
|
recovery_action_ids: HashSet<(AIConversationId, AIAgentActionId)>,
|
||||||
launched_agents: HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
launched_agents: HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
||||||
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
||||||
terminal_view_id: EntityId,
|
terminal_view_id: EntityId,
|
||||||
@@ -69,12 +78,20 @@ pub struct RunAgentsExecutor {
|
|||||||
/// Lifecycle events for in-flight dispatches.
|
/// Lifecycle events for in-flight dispatches.
|
||||||
pub enum RunAgentsExecutorEvent {
|
pub enum RunAgentsExecutorEvent {
|
||||||
SpawningStarted {
|
SpawningStarted {
|
||||||
|
conversation_id: AIConversationId,
|
||||||
action_id: AIAgentActionId,
|
action_id: AIAgentActionId,
|
||||||
snapshot: RunAgentsSpawningSnapshot,
|
snapshot: RunAgentsSpawningSnapshot,
|
||||||
},
|
},
|
||||||
SpawningFinished {
|
SpawningFinished {
|
||||||
|
conversation_id: AIConversationId,
|
||||||
action_id: AIAgentActionId,
|
action_id: AIAgentActionId,
|
||||||
},
|
},
|
||||||
|
ChildConversationCreated {
|
||||||
|
action_id: AIAgentActionId,
|
||||||
|
agent_name: String,
|
||||||
|
parent_conversation_id: AIConversationId,
|
||||||
|
child_conversation_id: AIConversationId,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Entity for RunAgentsExecutor {
|
impl Entity for RunAgentsExecutor {
|
||||||
@@ -85,31 +102,77 @@ impl RunAgentsExecutor {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
||||||
terminal_view_id: EntityId,
|
terminal_view_id: EntityId,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
ctx.subscribe_to_model(&start_agent_executor, |_, _, event, ctx| {
|
||||||
|
if let StartAgentExecutorEvent::RunAgentsChildConversationCreated {
|
||||||
|
action_id,
|
||||||
|
agent_name,
|
||||||
|
parent_conversation_id,
|
||||||
|
child_conversation_id,
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
ctx.emit(RunAgentsExecutorEvent::ChildConversationCreated {
|
||||||
|
action_id: action_id.clone(),
|
||||||
|
agent_name: agent_name.clone(),
|
||||||
|
parent_conversation_id: *parent_conversation_id,
|
||||||
|
child_conversation_id: *child_conversation_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Self {
|
Self {
|
||||||
pending: HashMap::new(),
|
pending: HashMap::new(),
|
||||||
|
recovery_action_ids: HashSet::new(),
|
||||||
launched_agents: HashMap::new(),
|
launched_agents: HashMap::new(),
|
||||||
start_agent_executor,
|
start_agent_executor,
|
||||||
terminal_view_id,
|
terminal_view_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_pending(&self, action_id: &AIAgentActionId) -> bool {
|
pub fn is_pending(
|
||||||
self.pending.contains_key(action_id)
|
&self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
action_id: &AIAgentActionId,
|
||||||
|
) -> bool {
|
||||||
|
self.pending
|
||||||
|
.contains_key(&(conversation_id, action_id.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancels a pending run so publication completion cannot fan out children.
|
pub fn mark_recovery_actions(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
action_ids: &HashSet<AIAgentActionId>,
|
||||||
|
) {
|
||||||
|
self.recovery_action_ids.extend(
|
||||||
|
action_ids
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|action_id| (conversation_id, action_id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn terminal_view_id(&self) -> EntityId {
|
||||||
|
self.terminal_view_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels the parent tool wait without cancelling independently-running children.
|
||||||
pub(super) fn cancel_execution(
|
pub(super) fn cancel_execution(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
action_id: &AIAgentActionId,
|
action_id: &AIAgentActionId,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
if matches!(
|
let action_key = (conversation_id, action_id.clone());
|
||||||
self.pending.get(action_id),
|
self.recovery_action_ids.remove(&action_key);
|
||||||
Some(PendingRunAgents::Publishing)
|
let detached_dispatches = self.start_agent_executor.update(ctx, |executor, _| {
|
||||||
) {
|
executor.cancel_dispatches_for_action(conversation_id, action_id)
|
||||||
self.pending.remove(action_id);
|
});
|
||||||
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
|
"RunAgents cancellation detached {detached_dispatches} pending child dispatch(es) for action {action_id}"
|
||||||
|
);
|
||||||
|
if self.pending.remove(&action_key).is_some() {
|
||||||
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
|
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
|
||||||
|
conversation_id,
|
||||||
action_id: action_id.clone(),
|
action_id: action_id.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -122,6 +185,22 @@ impl RunAgentsExecutor {
|
|||||||
) {
|
) {
|
||||||
for agent in agents {
|
for agent in agents {
|
||||||
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
|
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
|
||||||
|
let RunAgentsAgentOutcomeKind::Completed { agent_id, .. } = &agent.kind else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.launched_agents
|
||||||
|
.entry(conversation_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(
|
||||||
|
normalized_name,
|
||||||
|
ExistingLaunchedAgent {
|
||||||
|
name: agent.name.clone(),
|
||||||
|
agent_id: agent_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
|
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
|
||||||
@@ -164,14 +243,39 @@ impl RunAgentsExecutor {
|
|||||||
) -> async_channel::Receiver<RunAgentsResult> {
|
) -> async_channel::Receiver<RunAgentsResult> {
|
||||||
let (sender, receiver) = async_channel::bounded(1);
|
let (sender, receiver) = async_channel::bounded(1);
|
||||||
|
|
||||||
if self.pending.contains_key(&action_id) {
|
let action_key = (parent_conversation_id, action_id.clone());
|
||||||
|
if self.pending.contains_key(&action_key) {
|
||||||
log::warn!("RunAgentsExecutor: dispatch reentered for {action_id:?}; rejecting");
|
log::warn!("RunAgentsExecutor: dispatch reentered for {action_id:?}; rejecting");
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
log_run_agents_event(
|
||||||
|
ctx,
|
||||||
|
RemoteLogLevel::Warn,
|
||||||
|
"RunAgents dispatch rejected",
|
||||||
|
serde_json::json!({
|
||||||
|
"event": "run_agents_dispatch_rejected",
|
||||||
|
"reason": "reentered_pending_action",
|
||||||
|
"action_id": action_id.to_string(),
|
||||||
|
"parent_conversation_id": parent_conversation_id.to_string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
let _ = sender.try_send(RunAgentsResult::Cancelled);
|
let _ = sender.try_send(RunAgentsResult::Cancelled);
|
||||||
return receiver;
|
return receiver;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(error) = validate_request(&request) {
|
if let Err(error) = validate_request(&request) {
|
||||||
log::warn!("RunAgentsExecutor: validation failure: {error}");
|
log::warn!("RunAgentsExecutor: validation failure: {error}");
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
log_run_agents_event(
|
||||||
|
ctx,
|
||||||
|
RemoteLogLevel::Warn,
|
||||||
|
"RunAgents validation failed",
|
||||||
|
serde_json::json!({
|
||||||
|
"event": "run_agents_validation_failed",
|
||||||
|
"action_id": action_id.to_string(),
|
||||||
|
"parent_conversation_id": parent_conversation_id.to_string(),
|
||||||
|
"error": remote_logging::sanitize_error(&error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
let _ = sender.try_send(RunAgentsResult::Failure { error });
|
let _ = sender.try_send(RunAgentsResult::Failure { error });
|
||||||
return receiver;
|
return receiver;
|
||||||
}
|
}
|
||||||
@@ -181,8 +285,22 @@ impl RunAgentsExecutor {
|
|||||||
agent_count: request.agent_run_configs.len(),
|
agent_count: request.agent_run_configs.len(),
|
||||||
};
|
};
|
||||||
self.pending
|
self.pending
|
||||||
.insert(action_id.clone(), PendingRunAgents::Publishing);
|
.insert(action_key, PendingRunAgents::Publishing);
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
log_run_agents_event(
|
||||||
|
ctx,
|
||||||
|
RemoteLogLevel::Info,
|
||||||
|
"RunAgents plan publication wait started",
|
||||||
|
serde_json::json!({
|
||||||
|
"event": "run_agents_plan_publication_wait_started",
|
||||||
|
"action_id": action_id.to_string(),
|
||||||
|
"parent_conversation_id": parent_conversation_id.to_string(),
|
||||||
|
"agent_count": snapshot.agent_count,
|
||||||
|
"plan_id_present": !request.plan_id.trim().is_empty(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
ctx.emit(RunAgentsExecutorEvent::SpawningStarted {
|
ctx.emit(RunAgentsExecutorEvent::SpawningStarted {
|
||||||
|
conversation_id: parent_conversation_id,
|
||||||
action_id: action_id.clone(),
|
action_id: action_id.clone(),
|
||||||
snapshot,
|
snapshot,
|
||||||
});
|
});
|
||||||
@@ -197,13 +315,14 @@ impl RunAgentsExecutor {
|
|||||||
request
|
request
|
||||||
},
|
},
|
||||||
move |me, request, ctx| {
|
move |me, request, ctx| {
|
||||||
if !me.is_pending(&action_id_for_wait) {
|
if !me.is_pending(parent_conversation_id, &action_id_for_wait) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
me.dispatch_children_for_prepared_request(
|
me.dispatch_children_for_prepared_request(
|
||||||
action_id_for_wait.clone(),
|
action_id_for_wait.clone(),
|
||||||
request,
|
request,
|
||||||
parent_conversation_id,
|
parent_conversation_id,
|
||||||
|
HashMap::new(),
|
||||||
sender,
|
sender,
|
||||||
ctx,
|
ctx,
|
||||||
)
|
)
|
||||||
@@ -213,16 +332,56 @@ impl RunAgentsExecutor {
|
|||||||
receiver
|
receiver
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dispatch_recovered_run_agents(
|
||||||
|
&mut self,
|
||||||
|
action_id: AIAgentActionId,
|
||||||
|
request: RunAgentsRequest,
|
||||||
|
parent_conversation_id: AIConversationId,
|
||||||
|
recovery_children: HashMap<String, AIConversationId>,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) -> async_channel::Receiver<RunAgentsResult> {
|
||||||
|
let (sender, receiver) = async_channel::bounded(1);
|
||||||
|
if self.is_pending(parent_conversation_id, &action_id) {
|
||||||
|
let _ = sender.try_send(RunAgentsResult::Cancelled);
|
||||||
|
return receiver;
|
||||||
|
}
|
||||||
|
if let Err(error) = validate_request(&request) {
|
||||||
|
let _ = sender.try_send(RunAgentsResult::Failure { error });
|
||||||
|
return receiver;
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = RunAgentsSpawningSnapshot {
|
||||||
|
agent_count: request.agent_run_configs.len(),
|
||||||
|
};
|
||||||
|
ctx.emit(RunAgentsExecutorEvent::SpawningStarted {
|
||||||
|
conversation_id: parent_conversation_id,
|
||||||
|
action_id: action_id.clone(),
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
|
self.dispatch_children_for_prepared_request(
|
||||||
|
action_id,
|
||||||
|
request,
|
||||||
|
parent_conversation_id,
|
||||||
|
recovery_children,
|
||||||
|
sender,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
receiver
|
||||||
|
}
|
||||||
|
|
||||||
fn dispatch_children_for_prepared_request(
|
fn dispatch_children_for_prepared_request(
|
||||||
&mut self,
|
&mut self,
|
||||||
action_id: AIAgentActionId,
|
action_id: AIAgentActionId,
|
||||||
request: RunAgentsRequest,
|
request: RunAgentsRequest,
|
||||||
parent_conversation_id: AIConversationId,
|
parent_conversation_id: AIConversationId,
|
||||||
|
mut recovery_children: HashMap<String, AIConversationId>,
|
||||||
sender: async_channel::Sender<RunAgentsResult>,
|
sender: async_channel::Sender<RunAgentsResult>,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
self.pending
|
self.pending.insert(
|
||||||
.insert(action_id.clone(), PendingRunAgents::Spawning);
|
(parent_conversation_id, action_id.clone()),
|
||||||
|
PendingRunAgents::Spawning,
|
||||||
|
);
|
||||||
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
|
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||||
.conversation(&parent_conversation_id)
|
.conversation(&parent_conversation_id)
|
||||||
.and_then(|c| c.run_id());
|
.and_then(|c| c.run_id());
|
||||||
@@ -238,8 +397,46 @@ impl RunAgentsExecutor {
|
|||||||
..
|
..
|
||||||
} = request;
|
} = request;
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
log_run_agents_event(
|
||||||
|
ctx,
|
||||||
|
RemoteLogLevel::Info,
|
||||||
|
"RunAgents child dispatch started",
|
||||||
|
serde_json::json!({
|
||||||
|
"event": "run_agents_child_dispatch_started",
|
||||||
|
"action_id": action_id.to_string(),
|
||||||
|
"parent_conversation_id": parent_conversation_id.to_string(),
|
||||||
|
"agent_count": agent_run_configs.len(),
|
||||||
|
"execution_mode": run_agents_execution_mode_label(&run_execution_mode),
|
||||||
|
"harness_type": harness_type.as_str(),
|
||||||
|
"model_id_present": !model_id.trim().is_empty(),
|
||||||
|
"parent_run_id_present": parent_run_id.is_some(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len());
|
let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len());
|
||||||
|
let wait_policy = match &run_execution_mode {
|
||||||
|
RunAgentsExecutionMode::Local => StartAgentWaitPolicy::Completion,
|
||||||
|
RunAgentsExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
|
||||||
|
};
|
||||||
for cfg in &agent_run_configs {
|
for cfg in &agent_run_configs {
|
||||||
|
let normalized_name = normalize_agent_name(&cfg.name)
|
||||||
|
.expect("validated RunAgents requests have non-empty agent names");
|
||||||
|
if let Some(child_conversation_id) = recovery_children.remove(&normalized_name) {
|
||||||
|
let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
|
||||||
|
executor.reattach(
|
||||||
|
action_id.clone(),
|
||||||
|
cfg.name.clone(),
|
||||||
|
parent_conversation_id,
|
||||||
|
child_conversation_id,
|
||||||
|
wait_policy,
|
||||||
|
exec_ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
slots.push(ChildSlot::Pending(dispatch));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let prompt = compose_run_agents_child_prompt(&base_prompt, &cfg.prompt);
|
let prompt = compose_run_agents_child_prompt(&base_prompt, &cfg.prompt);
|
||||||
let mode = match run_agents_to_start_agent_mode(
|
let mode = match run_agents_to_start_agent_mode(
|
||||||
&run_execution_mode,
|
&run_execution_mode,
|
||||||
@@ -251,6 +448,19 @@ impl RunAgentsExecutor {
|
|||||||
) {
|
) {
|
||||||
Ok(mode) => mode,
|
Ok(mode) => mode,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
log_run_agents_event(
|
||||||
|
ctx,
|
||||||
|
RemoteLogLevel::Warn,
|
||||||
|
"RunAgents child dispatch failed before launch",
|
||||||
|
serde_json::json!({
|
||||||
|
"event": "run_agents_child_dispatch_prelaunch_failed",
|
||||||
|
"action_id": action_id.to_string(),
|
||||||
|
"parent_conversation_id": parent_conversation_id.to_string(),
|
||||||
|
"agent_name": cfg.name.as_str(),
|
||||||
|
"error": remote_logging::sanitize_error(&err),
|
||||||
|
}),
|
||||||
|
);
|
||||||
slots.push(ChildSlot::Failed(err));
|
slots.push(ChildSlot::Failed(err));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -258,13 +468,40 @@ impl RunAgentsExecutor {
|
|||||||
if matches!(run_execution_mode, RunAgentsExecutionMode::Remote { .. })
|
if matches!(run_execution_mode, RunAgentsExecutionMode::Remote { .. })
|
||||||
&& parent_run_id.is_none()
|
&& parent_run_id.is_none()
|
||||||
{
|
{
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
log_run_agents_event(
|
||||||
|
ctx,
|
||||||
|
RemoteLogLevel::Warn,
|
||||||
|
"RunAgents remote child dispatch missing parent run_id",
|
||||||
|
serde_json::json!({
|
||||||
|
"event": "run_agents_child_dispatch_prelaunch_failed",
|
||||||
|
"action_id": action_id.to_string(),
|
||||||
|
"parent_conversation_id": parent_conversation_id.to_string(),
|
||||||
|
"agent_name": cfg.name.as_str(),
|
||||||
|
"error": "Remote child agents require the parent run_id to be available.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
slots.push(ChildSlot::Failed(
|
slots.push(ChildSlot::Failed(
|
||||||
"Remote child agents require the parent run_id to be available.".to_string(),
|
"Remote child agents require the parent run_id to be available.".to_string(),
|
||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let recv = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
log_run_agents_event(
|
||||||
|
ctx,
|
||||||
|
RemoteLogLevel::Info,
|
||||||
|
"RunAgents child dispatch queued",
|
||||||
|
serde_json::json!({
|
||||||
|
"event": "run_agents_child_dispatch_queued",
|
||||||
|
"action_id": action_id.to_string(),
|
||||||
|
"parent_conversation_id": parent_conversation_id.to_string(),
|
||||||
|
"agent_name": cfg.name.as_str(),
|
||||||
|
"execution_mode": start_agent_execution_mode_label(&mode),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
|
||||||
executor.dispatch(
|
executor.dispatch(
|
||||||
|
action_id.clone(),
|
||||||
cfg.name.clone(),
|
cfg.name.clone(),
|
||||||
prompt,
|
prompt,
|
||||||
mode,
|
mode,
|
||||||
@@ -274,7 +511,7 @@ impl RunAgentsExecutor {
|
|||||||
exec_ctx,
|
exec_ctx,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
slots.push(ChildSlot::Pending(recv));
|
slots.push(ChildSlot::Pending(dispatch));
|
||||||
}
|
}
|
||||||
|
|
||||||
let agent_run_configs_for_result = agent_run_configs.clone();
|
let agent_run_configs_for_result = agent_run_configs.clone();
|
||||||
@@ -283,65 +520,95 @@ impl RunAgentsExecutor {
|
|||||||
let run_harness_type = harness_type.clone();
|
let run_harness_type = harness_type.clone();
|
||||||
let run_execution_mode_for_aggr = run_execution_mode.clone();
|
let run_execution_mode_for_aggr = run_execution_mode.clone();
|
||||||
let parent_conversation_id_for_result = parent_conversation_id;
|
let parent_conversation_id_for_result = parent_conversation_id;
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
let action_id_for_async_log = action_id.clone();
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
let parent_conversation_id_for_async_log = parent_conversation_id;
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
let agent_names_for_async_log = agent_run_configs
|
||||||
|
.iter()
|
||||||
|
.map(|cfg| cfg.name.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
ctx.spawn(
|
ctx.spawn(
|
||||||
async move {
|
async move {
|
||||||
let mut outcomes: Vec<RunAgentsAgentOutcomeKind> = Vec::with_capacity(slots.len());
|
let resolved_slots = join_all(slots.into_iter().map(resolve_child_slot)).await;
|
||||||
for slot in slots {
|
#[cfg(not(target_family = "wasm"))]
|
||||||
let kind = match slot {
|
for (slot_index, resolved) in resolved_slots.iter().enumerate() {
|
||||||
ChildSlot::Failed(error) => RunAgentsAgentOutcomeKind::Failed { error },
|
log::info!(
|
||||||
ChildSlot::Pending(recv) => {
|
"RunAgents child launch outcome action_id={} parent_conversation_id={} \
|
||||||
let timeout = warpui::r#async::Timer::after(SPAWN_TIMEOUT);
|
agent_name={} slot_index={} outcome={}",
|
||||||
match futures::future::select(Box::pin(recv.recv()), Box::pin(timeout))
|
action_id_for_async_log,
|
||||||
.await
|
parent_conversation_id_for_async_log,
|
||||||
{
|
agent_names_for_async_log
|
||||||
futures::future::Either::Left((
|
.get(slot_index)
|
||||||
Ok(StartAgentOutcome::Started { agent_id }),
|
.map(String::as_str)
|
||||||
_,
|
.unwrap_or("<unknown>"),
|
||||||
)) => RunAgentsAgentOutcomeKind::Launched { agent_id },
|
slot_index,
|
||||||
futures::future::Either::Left((
|
run_agents_agent_outcome_kind_label(&resolved.outcome)
|
||||||
Ok(StartAgentOutcome::Completed { agent_id, .. }),
|
);
|
||||||
_,
|
|
||||||
)) => RunAgentsAgentOutcomeKind::Launched { agent_id },
|
|
||||||
futures::future::Either::Left((
|
|
||||||
Ok(StartAgentOutcome::Error(error)),
|
|
||||||
_,
|
|
||||||
)) => RunAgentsAgentOutcomeKind::Failed { error },
|
|
||||||
futures::future::Either::Left((Err(_), _)) => {
|
|
||||||
RunAgentsAgentOutcomeKind::Failed {
|
|
||||||
error: "Cancelled before launch".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
futures::future::Either::Right((_, _)) => {
|
|
||||||
log::warn!(
|
|
||||||
"Agent spawn timed out after {} seconds",
|
|
||||||
SPAWN_TIMEOUT.as_secs()
|
|
||||||
);
|
|
||||||
RunAgentsAgentOutcomeKind::Failed {
|
|
||||||
error: format!(
|
|
||||||
"Agent failed to start within {} seconds. \
|
|
||||||
The harness binary may not be installed.",
|
|
||||||
SPAWN_TIMEOUT.as_secs()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
outcomes.push(kind);
|
|
||||||
}
|
}
|
||||||
outcomes
|
resolved_slots
|
||||||
},
|
},
|
||||||
move |me, outcomes, ctx| {
|
move |me, resolved_slots, ctx| {
|
||||||
|
if !me.is_pending(parent_conversation_id_for_result, &action_id_for_aggr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let timed_out_request_ids = resolved_slots
|
||||||
|
.iter()
|
||||||
|
.filter_map(|resolved| resolved.timed_out_request_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !timed_out_request_ids.is_empty() {
|
||||||
|
me.start_agent_executor.update(ctx, |executor, _| {
|
||||||
|
for request_id in timed_out_request_ids {
|
||||||
|
executor.detach_dispatch(request_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
let agents: Vec<RunAgentsAgentOutcome> = agent_run_configs_for_result
|
let agents: Vec<RunAgentsAgentOutcome> = agent_run_configs_for_result
|
||||||
.iter()
|
.iter()
|
||||||
.zip(outcomes)
|
.zip(resolved_slots)
|
||||||
.map(|(cfg, kind)| RunAgentsAgentOutcome {
|
.map(|(cfg, resolved)| RunAgentsAgentOutcome {
|
||||||
name: cfg.name.clone(),
|
name: cfg.name.clone(),
|
||||||
kind,
|
kind: resolved.outcome,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
me.record_launched_agents(parent_conversation_id_for_result, &agents);
|
me.record_launched_agents(parent_conversation_id_for_result, &agents);
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
log_run_agents_event(
|
||||||
|
ctx,
|
||||||
|
RemoteLogLevel::Info,
|
||||||
|
"RunAgents launch outcomes resolved",
|
||||||
|
serde_json::json!({
|
||||||
|
"event": "run_agents_launch_outcomes_resolved",
|
||||||
|
"action_id": action_id_for_aggr.to_string(),
|
||||||
|
"parent_conversation_id": parent_conversation_id_for_result.to_string(),
|
||||||
|
"agent_count": agents.len(),
|
||||||
|
"launched_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Launched { .. } | RunAgentsAgentOutcomeKind::Completed { .. })).count(),
|
||||||
|
"failed_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. })).count(),
|
||||||
|
"agents": agents
|
||||||
|
.iter()
|
||||||
|
.map(|agent| match &agent.kind {
|
||||||
|
RunAgentsAgentOutcomeKind::Launched { agent_id } => serde_json::json!({
|
||||||
|
"name": agent.name.as_str(),
|
||||||
|
"status": "launched",
|
||||||
|
"agent_id": agent_id.as_str(),
|
||||||
|
}),
|
||||||
|
RunAgentsAgentOutcomeKind::Completed { agent_id, output } => serde_json::json!({
|
||||||
|
"name": agent.name.as_str(),
|
||||||
|
"status": "completed",
|
||||||
|
"agent_id": agent_id.as_str(),
|
||||||
|
"output": output,
|
||||||
|
}),
|
||||||
|
RunAgentsAgentOutcomeKind::Failed { error } => serde_json::json!({
|
||||||
|
"name": agent.name.as_str(),
|
||||||
|
"status": "failed",
|
||||||
|
"error": remote_logging::sanitize_error(error),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
let launched_mode = match &run_execution_mode_for_aggr {
|
let launched_mode = match &run_execution_mode_for_aggr {
|
||||||
RunAgentsExecutionMode::Local => RunAgentsLaunchedExecutionMode::Local,
|
RunAgentsExecutionMode::Local => RunAgentsLaunchedExecutionMode::Local,
|
||||||
RunAgentsExecutionMode::Remote {
|
RunAgentsExecutionMode::Remote {
|
||||||
@@ -360,8 +627,12 @@ impl RunAgentsExecutor {
|
|||||||
execution_mode: launched_mode,
|
execution_mode: launched_mode,
|
||||||
agents,
|
agents,
|
||||||
};
|
};
|
||||||
me.pending.remove(&action_id_for_aggr);
|
me.pending.remove(&(
|
||||||
|
parent_conversation_id_for_result,
|
||||||
|
action_id_for_aggr.clone(),
|
||||||
|
));
|
||||||
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
|
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
|
||||||
|
conversation_id: parent_conversation_id_for_result,
|
||||||
action_id: action_id_for_aggr,
|
action_id: action_id_for_aggr,
|
||||||
});
|
});
|
||||||
let _ = sender.try_send(result);
|
let _ = sender.try_send(result);
|
||||||
@@ -381,20 +652,58 @@ impl RunAgentsExecutor {
|
|||||||
let mut request = request.clone();
|
let mut request = request.clone();
|
||||||
let action_id = id.clone();
|
let action_id = id.clone();
|
||||||
let parent_conversation_id = input.conversation_id;
|
let parent_conversation_id = input.conversation_id;
|
||||||
if let Some(reason) = prepare_request_for_execution(
|
let is_recovery = self
|
||||||
&mut request,
|
.recovery_action_ids
|
||||||
parent_conversation_id,
|
.remove(&(parent_conversation_id, action_id.clone()));
|
||||||
self.terminal_view_id,
|
|
||||||
&self.launched_agents,
|
|
||||||
ctx,
|
|
||||||
) {
|
|
||||||
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
|
|
||||||
RunAgentsResult::Denied { reason },
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let receiver =
|
let recovery_children = if is_recovery {
|
||||||
self.dispatch_prepared_run_agents(action_id, request, parent_conversation_id, ctx);
|
prepare_recovery_request_for_execution(&mut request, parent_conversation_id, ctx);
|
||||||
|
match recovery_children_by_name(parent_conversation_id, ctx) {
|
||||||
|
Ok(children) => children,
|
||||||
|
Err(error) => {
|
||||||
|
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
|
||||||
|
RunAgentsResult::Failure { error },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(reason) = prepare_request_for_execution(
|
||||||
|
&mut request,
|
||||||
|
parent_conversation_id,
|
||||||
|
self.terminal_view_id,
|
||||||
|
&self.launched_agents,
|
||||||
|
ctx,
|
||||||
|
) {
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
log_run_agents_event(
|
||||||
|
ctx,
|
||||||
|
RemoteLogLevel::Warn,
|
||||||
|
"RunAgents execution denied",
|
||||||
|
serde_json::json!({
|
||||||
|
"event": "run_agents_execution_denied",
|
||||||
|
"action_id": action_id.to_string(),
|
||||||
|
"parent_conversation_id": parent_conversation_id.to_string(),
|
||||||
|
"reason": remote_logging::sanitize_error(&reason),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
|
||||||
|
RunAgentsResult::Denied { reason },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
HashMap::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let receiver = if is_recovery {
|
||||||
|
self.dispatch_recovered_run_agents(
|
||||||
|
action_id,
|
||||||
|
request,
|
||||||
|
parent_conversation_id,
|
||||||
|
recovery_children,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
self.dispatch_prepared_run_agents(action_id, request, parent_conversation_id, ctx)
|
||||||
|
};
|
||||||
|
|
||||||
ActionExecution::new_async(
|
ActionExecution::new_async(
|
||||||
async move { receiver.recv().await },
|
async move { receiver.recv().await },
|
||||||
@@ -413,6 +722,9 @@ impl RunAgentsExecutor {
|
|||||||
let AIAgentActionType::RunAgents(request) = &input.action.action else {
|
let AIAgentActionType::RunAgents(request) = &input.action.action else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
if child_agent_delegation_denial_reason(input.conversation_id, ctx).is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if AppExecutionMode::as_ref(ctx).is_autonomous() {
|
if AppExecutionMode::as_ref(ctx).is_autonomous() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -444,9 +756,123 @@ impl RunAgentsExecutor {
|
|||||||
#[path = "run_agents_tests.rs"]
|
#[path = "run_agents_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn log_run_agents_event(
|
||||||
|
ctx: &mut ModelContext<RunAgentsExecutor>,
|
||||||
|
level: RemoteLogLevel,
|
||||||
|
message: impl Into<String>,
|
||||||
|
context: serde_json::Value,
|
||||||
|
) {
|
||||||
|
remote_logging::log_model_event(
|
||||||
|
ctx,
|
||||||
|
RemoteLogRecord {
|
||||||
|
level,
|
||||||
|
message: message.into(),
|
||||||
|
context,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn run_agents_execution_mode_label(mode: &RunAgentsExecutionMode) -> &'static str {
|
||||||
|
match mode {
|
||||||
|
RunAgentsExecutionMode::Local => "local",
|
||||||
|
RunAgentsExecutionMode::Remote { .. } => "remote",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn start_agent_execution_mode_label(mode: &StartAgentExecutionMode) -> &'static str {
|
||||||
|
match mode {
|
||||||
|
StartAgentExecutionMode::Local { .. } => "local",
|
||||||
|
StartAgentExecutionMode::Remote { .. } => "remote",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn run_agents_agent_outcome_kind_label(kind: &RunAgentsAgentOutcomeKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
RunAgentsAgentOutcomeKind::Launched { .. } => "launched",
|
||||||
|
RunAgentsAgentOutcomeKind::Completed { .. } => "completed",
|
||||||
|
RunAgentsAgentOutcomeKind::Failed { .. } => "failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum ChildSlot {
|
enum ChildSlot {
|
||||||
Failed(String),
|
Failed(String),
|
||||||
Pending(async_channel::Receiver<StartAgentOutcome>),
|
Pending(StartAgentDispatch),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ResolvedChildSlot {
|
||||||
|
outcome: RunAgentsAgentOutcomeKind,
|
||||||
|
timed_out_request_id: Option<StartAgentRequestId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_child_slot(slot: ChildSlot) -> ResolvedChildSlot {
|
||||||
|
resolve_child_slot_with_timeout(slot, SPAWN_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_child_slot_with_timeout(
|
||||||
|
slot: ChildSlot,
|
||||||
|
spawn_timeout: Duration,
|
||||||
|
) -> ResolvedChildSlot {
|
||||||
|
let dispatch = match slot {
|
||||||
|
ChildSlot::Failed(error) => {
|
||||||
|
return ResolvedChildSlot {
|
||||||
|
outcome: RunAgentsAgentOutcomeKind::Failed { error },
|
||||||
|
timed_out_request_id: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ChildSlot::Pending(dispatch) => dispatch,
|
||||||
|
};
|
||||||
|
let request_id = dispatch.request_id;
|
||||||
|
|
||||||
|
let outcome = match dispatch.wait_policy {
|
||||||
|
StartAgentWaitPolicy::Completion => dispatch.receiver.recv().await.ok(),
|
||||||
|
StartAgentWaitPolicy::Startup => {
|
||||||
|
let timeout = warpui::r#async::Timer::after(spawn_timeout);
|
||||||
|
match futures::future::select(Box::pin(dispatch.receiver.recv()), Box::pin(timeout))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
futures::future::Either::Left((outcome, _)) => outcome.ok(),
|
||||||
|
futures::future::Either::Right((_, _)) => {
|
||||||
|
dispatch.mark_detached();
|
||||||
|
log::warn!(
|
||||||
|
"Agent spawn timed out after {} seconds",
|
||||||
|
spawn_timeout.as_secs()
|
||||||
|
);
|
||||||
|
return ResolvedChildSlot {
|
||||||
|
outcome: RunAgentsAgentOutcomeKind::Failed {
|
||||||
|
error: format!(
|
||||||
|
"Agent failed to start within {} seconds. \
|
||||||
|
The harness binary may not be installed.",
|
||||||
|
spawn_timeout.as_secs()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
timed_out_request_id: Some(request_id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = match outcome {
|
||||||
|
Some(StartAgentOutcome::Started { agent_id }) => {
|
||||||
|
RunAgentsAgentOutcomeKind::Launched { agent_id }
|
||||||
|
}
|
||||||
|
Some(StartAgentOutcome::Completed { agent_id, output }) => {
|
||||||
|
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
|
||||||
|
}
|
||||||
|
Some(StartAgentOutcome::Error(error)) => RunAgentsAgentOutcomeKind::Failed { error },
|
||||||
|
None => RunAgentsAgentOutcomeKind::Failed {
|
||||||
|
error: "Child agent was cancelled before completion".to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ResolvedChildSlot {
|
||||||
|
outcome,
|
||||||
|
timed_out_request_id: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn approved_orchestration_config_can_autoexecute(
|
fn approved_orchestration_config_can_autoexecute(
|
||||||
@@ -476,9 +902,9 @@ fn resolve_request_from_approved_config(
|
|||||||
|
|
||||||
/// Normalizes the request and returns a denial reason when launch is blocked.
|
/// Normalizes the request and returns a denial reason when launch is blocked.
|
||||||
///
|
///
|
||||||
/// Autonomous agents always run: their calls may still inherit approved plan
|
/// Root autonomous agents bypass interactive policy denials because they cannot
|
||||||
/// config fields and default auth secrets, but they bypass interactive policy
|
/// present a confirmation card. Child-agent delegation is rejected before that
|
||||||
/// denials because they cannot present a confirmation card.
|
/// bypass, while allowed root calls still inherit approved config and auth fields.
|
||||||
fn prepare_request_for_execution(
|
fn prepare_request_for_execution(
|
||||||
request: &mut RunAgentsRequest,
|
request: &mut RunAgentsRequest,
|
||||||
parent_conversation_id: AIConversationId,
|
parent_conversation_id: AIConversationId,
|
||||||
@@ -486,6 +912,11 @@ fn prepare_request_for_execution(
|
|||||||
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
||||||
ctx: &ModelContext<RunAgentsExecutor>,
|
ctx: &ModelContext<RunAgentsExecutor>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
|
if let Some(reason) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
|
||||||
|
return Some(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_request_for_local_execution(request);
|
||||||
let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx);
|
let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx);
|
||||||
populate_default_auth_secret_for_execution(request, ctx);
|
populate_default_auth_secret_for_execution(request, ctx);
|
||||||
if let Some(reason) =
|
if let Some(reason) =
|
||||||
@@ -521,6 +952,42 @@ fn prepare_request_for_execution(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prepare_recovery_request_for_execution(
|
||||||
|
request: &mut RunAgentsRequest,
|
||||||
|
parent_conversation_id: AIConversationId,
|
||||||
|
ctx: &ModelContext<RunAgentsExecutor>,
|
||||||
|
) {
|
||||||
|
normalize_request_for_local_execution(request);
|
||||||
|
resolve_request_from_approved_config(request, parent_conversation_id, ctx);
|
||||||
|
populate_default_auth_secret_for_execution(request, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recovery_children_by_name(
|
||||||
|
parent_conversation_id: AIConversationId,
|
||||||
|
ctx: &ModelContext<RunAgentsExecutor>,
|
||||||
|
) -> Result<HashMap<String, AIConversationId>, String> {
|
||||||
|
let mut children_by_name = HashMap::new();
|
||||||
|
for conversation in
|
||||||
|
BlocklistAIHistoryModel::as_ref(ctx).child_conversations_of(parent_conversation_id)
|
||||||
|
{
|
||||||
|
let Some(name) = conversation.agent_name() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(normalized_name) = normalize_agent_name(name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if children_by_name
|
||||||
|
.insert(normalized_name.clone(), conversation.id())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"Cannot recover child agent '{name}': multiple persisted child conversations have the same name."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(children_by_name)
|
||||||
|
}
|
||||||
|
|
||||||
fn duplicate_launched_agents_reason(
|
fn duplicate_launched_agents_reason(
|
||||||
request: &RunAgentsRequest,
|
request: &RunAgentsRequest,
|
||||||
parent_conversation_id: AIConversationId,
|
parent_conversation_id: AIConversationId,
|
||||||
@@ -544,8 +1011,11 @@ fn duplicate_launched_agents_reason(
|
|||||||
|
|
||||||
let duplicates = requested_agents
|
let duplicates = requested_agents
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(normalized_name, _)| existing_agents.get(normalized_name))
|
.filter_map(|(normalized_name, _)| existing_agents.get(normalized_name))
|
||||||
.collect::<Option<Vec<_>>>()?;
|
.collect::<Vec<_>>();
|
||||||
|
if duplicates.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let duplicate_list = duplicates
|
let duplicate_list = duplicates
|
||||||
.iter()
|
.iter()
|
||||||
.map(|agent| format!("{} ({})", agent.name, agent.agent_id))
|
.map(|agent| format!("{} ({})", agent.name, agent.agent_id))
|
||||||
@@ -590,6 +1060,19 @@ fn existing_launched_agents_for_conversation(
|
|||||||
};
|
};
|
||||||
for agent in agents {
|
for agent in agents {
|
||||||
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
|
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
|
||||||
|
let RunAgentsAgentOutcomeKind::Completed { agent_id, .. } = &agent.kind
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
existing_agents.entry(normalized_name).or_insert_with(|| {
|
||||||
|
ExistingLaunchedAgent {
|
||||||
|
name: agent.name.clone(),
|
||||||
|
agent_id: agent_id.clone(),
|
||||||
|
}
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
|
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
|
||||||
@@ -673,6 +1156,18 @@ fn populate_default_auth_secret_for_execution(
|
|||||||
default_auth_secret_name_for_harness(&request.harness_type, ctx);
|
default_auth_secret_name_for_harness(&request.harness_type, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_request_for_local_execution(request: &mut RunAgentsRequest) {
|
||||||
|
let edit_state = OrchestrationEditState::from_run_agents_fields(
|
||||||
|
&request.model_id,
|
||||||
|
&request.harness_type,
|
||||||
|
&request.execution_mode,
|
||||||
|
);
|
||||||
|
request.model_id = edit_state.model_id;
|
||||||
|
request.harness_type = edit_state.harness_type;
|
||||||
|
request.execution_mode = RunAgentsExecutionMode::Local;
|
||||||
|
request.harness_auth_secret_name = None;
|
||||||
|
}
|
||||||
|
|
||||||
/// Unconditionally overrides run-wide fields on a `RunAgentsRequest`
|
/// Unconditionally overrides run-wide fields on a `RunAgentsRequest`
|
||||||
/// from the approved orchestration config, delegating to
|
/// from the approved orchestration config, delegating to
|
||||||
/// `OrchestrationEditState::override_from_approved_config`.
|
/// `OrchestrationEditState::override_from_approved_config`.
|
||||||
@@ -696,6 +1191,23 @@ fn validate_request(request: &RunAgentsRequest) -> Result<(), String> {
|
|||||||
if request.agent_run_configs.is_empty() {
|
if request.agent_run_configs.is_empty() {
|
||||||
return Err("orchestrate: empty agent_run_configs".to_string());
|
return Err("orchestrate: empty agent_run_configs".to_string());
|
||||||
}
|
}
|
||||||
|
if request.execution_mode.is_remote() {
|
||||||
|
return Err("Galaxy only supports local child-agent orchestration.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut normalized_names = HashSet::new();
|
||||||
|
for config in &request.agent_run_configs {
|
||||||
|
let Some(normalized_name) = normalize_agent_name(&config.name) else {
|
||||||
|
return Err("orchestrate: agent names must not be empty".to_string());
|
||||||
|
};
|
||||||
|
if !normalized_names.insert(normalized_name) {
|
||||||
|
return Err(format!(
|
||||||
|
"orchestrate: duplicate agent name '{}' in the same batch",
|
||||||
|
config.name.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if matches!(request.execution_mode, RunAgentsExecutionMode::Local) {
|
if matches!(request.execution_mode, RunAgentsExecutionMode::Local) {
|
||||||
if let Some(harness) = Harness::parse_local_child_harness(&request.harness_type) {
|
if let Some(harness) = Harness::parse_local_child_harness(&request.harness_type) {
|
||||||
if let Some(message) = local_harness_product_disabled_message(harness) {
|
if let Some(message) = local_harness_product_disabled_message(harness) {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
|
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
|
||||||
use ai::agent::orchestration_config::{
|
use ai::agent::orchestration_config::{
|
||||||
@@ -91,6 +93,15 @@ fn persist_plan_config_with_harness(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mark_conversation_as_child(app: &mut App, conversation_id: AIConversationId) {
|
||||||
|
BlocklistAIHistoryModel::handle(app).update(app, |history, _ctx| {
|
||||||
|
history
|
||||||
|
.conversation_mut(&conversation_id)
|
||||||
|
.expect("conversation should exist")
|
||||||
|
.set_parent_agent_id("parent-agent".to_string());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_autoexecute_duplicate_launched_agent_denial() {
|
fn should_autoexecute_duplicate_launched_agent_denial() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
@@ -162,6 +173,522 @@ fn execute_denies_duplicate_launched_agent() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_denies_run_agents_from_child_conversation() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||||
|
mark_conversation_as_child(&mut app, state.conversation_id);
|
||||||
|
let action = remote_run_agents_action("oz");
|
||||||
|
|
||||||
|
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor.should_autoexecute(
|
||||||
|
ExecuteActionInput {
|
||||||
|
action: &action,
|
||||||
|
conversation_id: state.conversation_id,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
should_autoexecute,
|
||||||
|
"the denial should not require user approval"
|
||||||
|
);
|
||||||
|
|
||||||
|
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor
|
||||||
|
.execute(
|
||||||
|
ExecuteActionInput {
|
||||||
|
action: &action,
|
||||||
|
conversation_id: state.conversation_id,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
execution,
|
||||||
|
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
|
||||||
|
RunAgentsResult::Denied { reason }
|
||||||
|
)) if reason.contains("leaf workers")
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn autonomous_mode_still_denies_run_agents_from_child_conversation() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
|
||||||
|
mark_conversation_as_child(&mut app, state.conversation_id);
|
||||||
|
let action = remote_run_agents_action("oz");
|
||||||
|
|
||||||
|
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor
|
||||||
|
.execute(
|
||||||
|
ExecuteActionInput {
|
||||||
|
action: &action,
|
||||||
|
conversation_id: state.conversation_id,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
execution,
|
||||||
|
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
|
||||||
|
RunAgentsResult::Denied { reason }
|
||||||
|
)) if reason.contains("leaf workers")
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_denies_mixed_batch_containing_launched_agent() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||||
|
state.executor.update(&mut app, |executor, _ctx| {
|
||||||
|
executor.record_launched_agents(
|
||||||
|
state.conversation_id,
|
||||||
|
&[RunAgentsAgentOutcome {
|
||||||
|
name: "child".to_string(),
|
||||||
|
kind: RunAgentsAgentOutcomeKind::Launched {
|
||||||
|
agent_id: "agent-123".to_string(),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let mut action = remote_run_agents_action("oz");
|
||||||
|
let AIAgentActionType::RunAgents(request) = &mut action.action else {
|
||||||
|
panic!("expected run_agents action");
|
||||||
|
};
|
||||||
|
request.agent_run_configs.push(RunAgentsAgentRunConfig {
|
||||||
|
name: "new-child".to_string(),
|
||||||
|
prompt: "Do separate work".to_string(),
|
||||||
|
title: String::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor
|
||||||
|
.execute(
|
||||||
|
ExecuteActionInput {
|
||||||
|
action: &action,
|
||||||
|
conversation_id: state.conversation_id,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
execution,
|
||||||
|
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
|
||||||
|
RunAgentsResult::Denied { reason }
|
||||||
|
)) if reason.contains("child (agent-123)")
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_request_rejects_blank_and_duplicate_agent_names() {
|
||||||
|
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
|
||||||
|
panic!("expected run_agents action");
|
||||||
|
};
|
||||||
|
normalize_request_for_local_execution(&mut request);
|
||||||
|
request.agent_run_configs[0].name = " ".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
validate_request(&request),
|
||||||
|
Err("orchestrate: agent names must not be empty".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
request.agent_run_configs[0].name = "Child".to_string();
|
||||||
|
request.agent_run_configs.push(RunAgentsAgentRunConfig {
|
||||||
|
name: " child ".to_string(),
|
||||||
|
prompt: "Do separate work".to_string(),
|
||||||
|
title: String::new(),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
validate_request(&request),
|
||||||
|
Err("orchestrate: duplicate agent name 'child' in the same batch".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_request_allows_unique_sibling_names() {
|
||||||
|
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
|
||||||
|
panic!("expected run_agents action");
|
||||||
|
};
|
||||||
|
normalize_request_for_local_execution(&mut request);
|
||||||
|
request.agent_run_configs.push(RunAgentsAgentRunConfig {
|
||||||
|
name: "second-child".to_string(),
|
||||||
|
prompt: "Do separate work".to_string(),
|
||||||
|
title: String::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(validate_request(&request), Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_normalization_clears_remote_only_fields_and_disabled_harness() {
|
||||||
|
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("codex").action else {
|
||||||
|
panic!("expected run_agents action");
|
||||||
|
};
|
||||||
|
request.model_id = "gpt-5".to_string();
|
||||||
|
request.harness_auth_secret_name = Some("remote-secret".to_string());
|
||||||
|
|
||||||
|
normalize_request_for_local_execution(&mut request);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
request.execution_mode,
|
||||||
|
RunAgentsExecutionMode::Local
|
||||||
|
));
|
||||||
|
assert_eq!(request.harness_type, "oz");
|
||||||
|
assert_eq!(request.model_id, "");
|
||||||
|
assert_eq!(request.harness_auth_secret_name, None);
|
||||||
|
assert_eq!(validate_request(&request), Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_request_rejects_remote_dispatch() {
|
||||||
|
let AIAgentActionType::RunAgents(request) = remote_run_agents_action("oz").action else {
|
||||||
|
panic!("expected run_agents action");
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validate_request(&request),
|
||||||
|
Err("Galaxy only supports local child-agent orchestration.".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovered_run_agents_reattaches_existing_child_and_launches_only_missing_child() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let history = BlocklistAIHistoryModel::handle(&app);
|
||||||
|
let existing_child_id = history.update(&mut app, |history, ctx| {
|
||||||
|
history.start_new_child_conversation(
|
||||||
|
terminal_view_id,
|
||||||
|
"child".to_string(),
|
||||||
|
state.conversation_id,
|
||||||
|
None,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let captured = subscribe_to_start_agent_requests(&mut app, &state.start_agent_executor);
|
||||||
|
let mut action = remote_run_agents_action("oz");
|
||||||
|
let AIAgentActionType::RunAgents(request) = &mut action.action else {
|
||||||
|
panic!("expected run_agents action");
|
||||||
|
};
|
||||||
|
request.agent_run_configs.push(RunAgentsAgentRunConfig {
|
||||||
|
name: "missing-child".to_string(),
|
||||||
|
prompt: "Do separate work".to_string(),
|
||||||
|
title: String::new(),
|
||||||
|
});
|
||||||
|
state.executor.update(&mut app, |executor, _| {
|
||||||
|
executor
|
||||||
|
.mark_recovery_actions(state.conversation_id, &HashSet::from([action.id.clone()]));
|
||||||
|
});
|
||||||
|
|
||||||
|
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor
|
||||||
|
.execute(
|
||||||
|
ExecuteActionInput {
|
||||||
|
action: &action,
|
||||||
|
conversation_id: state.conversation_id,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
let AnyActionExecution::Async {
|
||||||
|
execute_future,
|
||||||
|
on_complete,
|
||||||
|
} = execution
|
||||||
|
else {
|
||||||
|
panic!("expected async recovery execution");
|
||||||
|
};
|
||||||
|
let missing_request = captured.read(&app, |captured, _| {
|
||||||
|
assert_eq!(captured.0.len(), 1);
|
||||||
|
assert_eq!(captured.0[0].name, "missing-child");
|
||||||
|
captured.0[0].clone()
|
||||||
|
});
|
||||||
|
|
||||||
|
history.update(&mut app, |history, ctx| {
|
||||||
|
history.update_conversation_status(
|
||||||
|
terminal_view_id,
|
||||||
|
existing_child_id,
|
||||||
|
crate::ai::agent::conversation::ConversationStatus::Success,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let missing_child_id = history.update(&mut app, |history, ctx| {
|
||||||
|
history.start_new_child_conversation(
|
||||||
|
terminal_view_id,
|
||||||
|
"missing-child".to_string(),
|
||||||
|
state.conversation_id,
|
||||||
|
None,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
history.update(&mut app, |history, ctx| {
|
||||||
|
history.record_new_conversation_request_complete(
|
||||||
|
missing_request.id,
|
||||||
|
missing_child_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
history.update_conversation_status(
|
||||||
|
terminal_view_id,
|
||||||
|
missing_child_id,
|
||||||
|
crate::ai::agent::conversation::ConversationStatus::Success,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let async_result = execute_future.await;
|
||||||
|
let result = app.update(|ctx| on_complete(async_result, ctx));
|
||||||
|
let AIAgentActionResultType::RunAgents(RunAgentsResult::Launched { agents, .. }) = result
|
||||||
|
else {
|
||||||
|
panic!("expected recovered RunAgents result");
|
||||||
|
};
|
||||||
|
assert_eq!(agents.len(), 2);
|
||||||
|
assert!(matches!(
|
||||||
|
&agents[0].kind,
|
||||||
|
RunAgentsAgentOutcomeKind::Launched { agent_id }
|
||||||
|
if agent_id == &existing_child_id.to_string()
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
&agents[1].kind,
|
||||||
|
RunAgentsAgentOutcomeKind::Launched { agent_id }
|
||||||
|
if agent_id == &missing_child_id.to_string()
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelling_recovered_run_agents_keeps_persisted_child_running() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let history = BlocklistAIHistoryModel::handle(&app);
|
||||||
|
let child_id = history.update(&mut app, |history, ctx| {
|
||||||
|
history.start_new_child_conversation(
|
||||||
|
terminal_view_id,
|
||||||
|
"child".to_string(),
|
||||||
|
state.conversation_id,
|
||||||
|
None,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let action = remote_run_agents_action("oz");
|
||||||
|
state.executor.update(&mut app, |executor, _| {
|
||||||
|
executor
|
||||||
|
.mark_recovery_actions(state.conversation_id, &HashSet::from([action.id.clone()]));
|
||||||
|
});
|
||||||
|
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor
|
||||||
|
.execute(
|
||||||
|
ExecuteActionInput {
|
||||||
|
action: &action,
|
||||||
|
conversation_id: state.conversation_id,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
let AnyActionExecution::Async {
|
||||||
|
execute_future,
|
||||||
|
on_complete,
|
||||||
|
} = execution
|
||||||
|
else {
|
||||||
|
panic!("expected async recovery execution");
|
||||||
|
};
|
||||||
|
|
||||||
|
state.executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor.cancel_execution(state.conversation_id, &action.id, ctx);
|
||||||
|
});
|
||||||
|
let async_result = execute_future.await;
|
||||||
|
let result = app.update(|ctx| on_complete(async_result, ctx));
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
AIAgentActionResultType::RunAgents(RunAgentsResult::Cancelled)
|
||||||
|
));
|
||||||
|
history.read(&app, |history, _| {
|
||||||
|
assert!(matches!(
|
||||||
|
history.conversation(&child_id).map(|child| child.status()),
|
||||||
|
Some(crate::ai::agent::conversation::ConversationStatus::InProgress)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_slots_are_polled_concurrently_and_preserve_request_order() {
|
||||||
|
App::test((), |_app| async move {
|
||||||
|
let (first_sender, first_receiver) = async_channel::bounded(1);
|
||||||
|
let (second_sender, second_receiver) = async_channel::bounded(1);
|
||||||
|
let slots = vec![
|
||||||
|
ChildSlot::Pending(StartAgentDispatch {
|
||||||
|
request_id: StartAgentRequestId::from_raw_for_test(1),
|
||||||
|
receiver: first_receiver,
|
||||||
|
wait_policy: StartAgentWaitPolicy::Completion,
|
||||||
|
detached: Arc::new(AtomicBool::new(false)),
|
||||||
|
}),
|
||||||
|
ChildSlot::Pending(StartAgentDispatch {
|
||||||
|
request_id: StartAgentRequestId::from_raw_for_test(2),
|
||||||
|
receiver: second_receiver,
|
||||||
|
wait_policy: StartAgentWaitPolicy::Completion,
|
||||||
|
detached: Arc::new(AtomicBool::new(false)),
|
||||||
|
}),
|
||||||
|
ChildSlot::Failed("prelaunch failure".to_string()),
|
||||||
|
];
|
||||||
|
let mut outcomes =
|
||||||
|
Box::pin(join_all(slots.into_iter().map(|slot| {
|
||||||
|
resolve_child_slot_with_timeout(slot, Duration::from_millis(1))
|
||||||
|
})));
|
||||||
|
|
||||||
|
second_sender
|
||||||
|
.try_send(StartAgentOutcome::Completed {
|
||||||
|
agent_id: "second-agent".to_string(),
|
||||||
|
output: "done".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(futures::poll!(&mut outcomes).is_pending());
|
||||||
|
assert!(
|
||||||
|
!second_sender.is_full(),
|
||||||
|
"join_all should poll and drain the second slot while the first is pending"
|
||||||
|
);
|
||||||
|
|
||||||
|
first_sender
|
||||||
|
.try_send(StartAgentOutcome::Error("first failed".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
let outcomes = outcomes.await;
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
&outcomes[0].outcome,
|
||||||
|
RunAgentsAgentOutcomeKind::Failed { error } if error == "first failed"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
&outcomes[1].outcome,
|
||||||
|
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
|
||||||
|
if agent_id == "second-agent" && output == "done"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
&outcomes[2].outcome,
|
||||||
|
RunAgentsAgentOutcomeKind::Failed { error } if error == "prelaunch failure"
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_wait_ignores_spawn_timeout() {
|
||||||
|
App::test((), |_app| async move {
|
||||||
|
let (sender, receiver) = async_channel::bounded(1);
|
||||||
|
let completion = Box::pin(resolve_child_slot_with_timeout(
|
||||||
|
ChildSlot::Pending(StartAgentDispatch {
|
||||||
|
request_id: StartAgentRequestId::from_raw_for_test(1),
|
||||||
|
receiver,
|
||||||
|
wait_policy: StartAgentWaitPolicy::Completion,
|
||||||
|
detached: Arc::new(AtomicBool::new(false)),
|
||||||
|
}),
|
||||||
|
Duration::from_millis(1),
|
||||||
|
));
|
||||||
|
let wait = warpui::r#async::Timer::after(Duration::from_millis(20));
|
||||||
|
|
||||||
|
let completion = match futures::future::select(completion, Box::pin(wait)).await {
|
||||||
|
futures::future::Either::Left((outcome, _)) => {
|
||||||
|
panic!("completion wait unexpectedly resolved before child completion: {outcome:?}")
|
||||||
|
}
|
||||||
|
futures::future::Either::Right((_, completion)) => completion,
|
||||||
|
};
|
||||||
|
sender
|
||||||
|
.try_send(StartAgentOutcome::Completed {
|
||||||
|
agent_id: "child-agent".to_string(),
|
||||||
|
output: "done".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
completion.await.outcome,
|
||||||
|
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
|
||||||
|
if agent_id == "child-agent" && output == "done"
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_wait_retains_spawn_timeout() {
|
||||||
|
App::test((), |_app| async move {
|
||||||
|
let (_sender, receiver) = async_channel::bounded(1);
|
||||||
|
let outcome = resolve_child_slot_with_timeout(
|
||||||
|
ChildSlot::Pending(StartAgentDispatch {
|
||||||
|
request_id: StartAgentRequestId::from_raw_for_test(1),
|
||||||
|
receiver,
|
||||||
|
wait_policy: StartAgentWaitPolicy::Startup,
|
||||||
|
detached: Arc::new(AtomicBool::new(false)),
|
||||||
|
}),
|
||||||
|
Duration::from_millis(1),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(outcome.timed_out_request_id.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
outcome.timed_out_request_id,
|
||||||
|
Some(StartAgentRequestId::from_raw_for_test(1))
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
outcome.outcome,
|
||||||
|
RunAgentsAgentOutcomeKind::Failed { error }
|
||||||
|
if error.contains("Agent failed to start within")
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_timeout_detaches_exact_pending_request() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||||
|
let start_agent_executor = state.start_agent_executor;
|
||||||
|
let parent_conversation_id = state.conversation_id;
|
||||||
|
let dispatch = start_agent_executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor.dispatch(
|
||||||
|
AIAgentActionId::from("run-agents-timeout".to_string()),
|
||||||
|
"child".to_string(),
|
||||||
|
"work".to_string(),
|
||||||
|
StartAgentExecutionMode::Remote {
|
||||||
|
environment_id: "environment".to_string(),
|
||||||
|
skill_references: Vec::new(),
|
||||||
|
model_id: "model".to_string(),
|
||||||
|
computer_use_enabled: false,
|
||||||
|
worker_host: String::new(),
|
||||||
|
harness_type: "oz".to_string(),
|
||||||
|
title: String::new(),
|
||||||
|
auth_secret_name: None,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
parent_conversation_id,
|
||||||
|
Some("parent-run".to_string()),
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let request_id = dispatch.request_id;
|
||||||
|
|
||||||
|
let resolved =
|
||||||
|
resolve_child_slot_with_timeout(ChildSlot::Pending(dispatch), Duration::from_millis(1))
|
||||||
|
.await;
|
||||||
|
let timed_out_request_id = resolved
|
||||||
|
.timed_out_request_id
|
||||||
|
.expect("startup timeout should expose request identity");
|
||||||
|
start_agent_executor.update(&mut app, |executor, _| {
|
||||||
|
assert!(executor.detach_dispatch(timed_out_request_id));
|
||||||
|
});
|
||||||
|
|
||||||
|
start_agent_executor.read(&app, |executor, _| {
|
||||||
|
assert!(!executor.has_pending_dispatch_for_test(request_id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
|
fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
|
||||||
initialize_settings_for_tests_with_mode(app, mode, false);
|
initialize_settings_for_tests_with_mode(app, mode, false);
|
||||||
let global_resource_handles = GlobalResourceHandles::mock(app);
|
let global_resource_handles = GlobalResourceHandles::mock(app);
|
||||||
@@ -178,6 +705,9 @@ fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTe
|
|||||||
app.add_singleton_model(TeamTesterStatus::mock);
|
app.add_singleton_model(TeamTesterStatus::mock);
|
||||||
app.add_singleton_model(UpdateManager::mock);
|
app.add_singleton_model(UpdateManager::mock);
|
||||||
app.add_singleton_model(CloudModel::mock);
|
app.add_singleton_model(CloudModel::mock);
|
||||||
|
app.add_singleton_model(|ctx| {
|
||||||
|
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
|
||||||
|
});
|
||||||
app.add_singleton_model(|_| Appearance::mock());
|
app.add_singleton_model(|_| Appearance::mock());
|
||||||
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
|
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
|
||||||
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
|
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
|
||||||
@@ -190,8 +720,9 @@ fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTe
|
|||||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||||
});
|
});
|
||||||
let start_agent_executor = app.add_model(StartAgentExecutor::new);
|
let start_agent_executor = app.add_model(StartAgentExecutor::new);
|
||||||
let executor =
|
let executor = app.add_model(|ctx| {
|
||||||
app.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id));
|
RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id, ctx)
|
||||||
|
});
|
||||||
|
|
||||||
RunAgentsTestState {
|
RunAgentsTestState {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
@@ -321,7 +852,7 @@ fn should_autoexecute_when_plan_has_approved_orchestration_config() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_secret() {
|
fn approved_remote_plan_is_normalized_and_can_autoexecute_locally() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||||
persist_plan_config_with_harness(
|
persist_plan_config_with_harness(
|
||||||
@@ -343,7 +874,7 @@ fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_sec
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(!should_autoexecute);
|
assert!(should_autoexecute);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,9 +1092,9 @@ fn cancel_during_plan_publication_does_not_dispatch_children() {
|
|||||||
// The action is awaiting plan publication, so it's pending but no children dispatched yet.
|
// The action is awaiting plan publication, so it's pending but no children dispatched yet.
|
||||||
assert!(matches!(execution, AnyActionExecution::Async { .. }));
|
assert!(matches!(execution, AnyActionExecution::Async { .. }));
|
||||||
state.executor.update(&mut app, |executor, ctx| {
|
state.executor.update(&mut app, |executor, ctx| {
|
||||||
assert!(executor.is_pending(&action_id));
|
assert!(executor.is_pending(state.conversation_id, &action_id));
|
||||||
executor.cancel_execution(&action_id, ctx);
|
executor.cancel_execution(state.conversation_id, &action_id, ctx);
|
||||||
assert!(!executor.is_pending(&action_id));
|
assert!(!executor.is_pending(state.conversation_id, &action_id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Finish publishing the plan, which resolves the wait the dispatch was blocked on.
|
// Finish publishing the plan, which resolves the wait the dispatch was blocked on.
|
||||||
@@ -617,7 +1148,7 @@ fn should_not_autoexecute_without_approved_plan_or_always_allow_profile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn execute_denies_remote_non_warp_harness_without_default_auth_secret() {
|
fn execute_normalizes_remote_non_oz_harness_without_requiring_remote_auth() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||||
let action = remote_run_agents_action("codex");
|
let action = remote_run_agents_action("codex");
|
||||||
@@ -634,21 +1165,12 @@ fn execute_denies_remote_non_warp_harness_without_default_auth_secret() {
|
|||||||
.into()
|
.into()
|
||||||
});
|
});
|
||||||
|
|
||||||
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
|
assert!(matches!(execution, AnyActionExecution::Async { .. }));
|
||||||
reason,
|
|
||||||
})) = execution
|
|
||||||
else {
|
|
||||||
panic!("expected synchronous run_agents denial");
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
reason,
|
|
||||||
"Cloud child agents using this harness require an API key before they can run."
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_without_default_auth_secret() {
|
fn normalized_remote_non_oz_harness_autoexecutes_with_always_allow() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||||
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
|
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
|
||||||
@@ -669,7 +1191,7 @@ fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_without_def
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() {
|
fn normalized_remote_non_oz_harness_ignores_default_auth_secret() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||||
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
|
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
|
||||||
@@ -691,7 +1213,7 @@ fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_autoexecute_remote_warp_harness_without_default_auth_secret() {
|
fn normalized_remote_oz_harness_autoexecutes_without_default_auth_secret() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||||
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
|
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use galaxy_core::execution_mode::AppExecutionMode;
|
|||||||
use galaxy_util::path::ShellFamily;
|
use galaxy_util::path::ShellFamily;
|
||||||
use galaxyui::r#async::{Spawnable, Timer};
|
use galaxyui::r#async::{Spawnable, Timer};
|
||||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||||
use itertools::Itertools;
|
|
||||||
use parking_lot::FairMutex;
|
use parking_lot::FairMutex;
|
||||||
|
|
||||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||||
@@ -37,11 +36,11 @@ use crate::{send_telemetry_from_ctx, TelemetryEvent};
|
|||||||
|
|
||||||
pub struct ShellCommandExecutor {
|
pub struct ShellCommandExecutor {
|
||||||
active_session: ModelHandle<ActiveSession>,
|
active_session: ModelHandle<ActiveSession>,
|
||||||
block_finished_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
|
block_finished_senders: HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
|
||||||
/// Senders used by `Check now` and the automatic monitor watchdog to force a long-running
|
/// Senders used by `Check now` and the automatic monitor watchdog to force a long-running
|
||||||
/// shell command's pending poll future to resolve immediately with a fresh snapshot,
|
/// shell command's pending poll future to resolve immediately with a fresh snapshot,
|
||||||
/// bypassing the agent-set timeout.
|
/// bypassing the agent-set timeout.
|
||||||
force_refresh_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
|
force_refresh_senders: HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
|
||||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||||
terminal_view_id: EntityId,
|
terminal_view_id: EntityId,
|
||||||
/// Sender to notify when user hands control back to agent after TransferShellCommandControlToUser.
|
/// Sender to notify when user hands control back to agent after TransferShellCommandControlToUser.
|
||||||
@@ -80,24 +79,39 @@ impl ShellCommandExecutor {
|
|||||||
event: &ModelEvent,
|
event: &ModelEvent,
|
||||||
_ctx: &mut ModelContext<Self>,
|
_ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
// We wait for precmd for the block _after_ the requested command's block so that
|
// Precmd provides fresh CWD metadata, while BlockCompleted is definitive completion
|
||||||
// downstream checks for current working directory are fresh. The precmd hook is when
|
// evidence for shells that never deliver a subsequent precmd.
|
||||||
// the shell relays current working directory to warp.
|
if matches!(
|
||||||
if let ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. }) = event {
|
event,
|
||||||
|
ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. })
|
||||||
|
| ModelEvent::BlockCompleted(_)
|
||||||
|
) {
|
||||||
let model = self.terminal_model.lock();
|
let model = self.terminal_model.lock();
|
||||||
let block_finished_senders = self.block_finished_senders.drain().collect_vec();
|
let block_finished_senders = self.block_finished_senders.drain().collect::<Vec<_>>();
|
||||||
for (block_selector, block_finished_tx) in block_finished_senders.into_iter() {
|
for (block_selector, block_finished_txs) in block_finished_senders {
|
||||||
if let Some(block) = block_selector.get_block(&model) {
|
let completed_block = block_selector.get_block(&model).filter(|block| {
|
||||||
if block.is_command_finished() {
|
block.is_command_finished()
|
||||||
|
&& match event {
|
||||||
|
ModelEvent::BlockCompleted(completed) => {
|
||||||
|
block.id() == &completed.block_id
|
||||||
|
}
|
||||||
|
ModelEvent::BlockMetadataReceived(_) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if completed_block.is_some() {
|
||||||
|
for block_finished_tx in block_finished_txs {
|
||||||
if let Err(e) = block_finished_tx.send(()) {
|
if let Err(e) = block_finished_tx.send(()) {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Failed to notify block completion for running requested command: {e:?}"
|
"Failed to notify block completion for running requested command: {e:?}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
self.block_finished_senders
|
|
||||||
.insert(block_selector, block_finished_tx);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// The requested-command association may not exist yet. Keep all waiters until
|
||||||
|
// this selector resolves and its block actually completes, or it is cancelled.
|
||||||
|
self.block_finished_senders
|
||||||
|
.insert(block_selector, block_finished_txs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,30 +204,13 @@ impl ShellCommandExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decorate the command so that we can turn off pager.
|
|
||||||
fn turn_off_pager_for_command(&self, command: &String, ctx: &mut ModelContext<Self>) -> String {
|
|
||||||
match self.active_session.as_ref(ctx).shell_type(ctx) {
|
|
||||||
// If it's a posix shell, we can use parentheses as the grouping character. Add command to
|
|
||||||
// avoid cases with aliases.
|
|
||||||
Some(ShellType::Zsh) | Some(ShellType::Bash) => format!("({command}) | command cat"),
|
|
||||||
// Fish doesn't have grouping characters. We need to use begin; and end; to ensure the command
|
|
||||||
// gets evaluated first.
|
|
||||||
Some(ShellType::Fish) => format!("begin; {command} ;end | command cat"),
|
|
||||||
// For powershell, we use Out-Host to send paged output to the
|
|
||||||
// console. Add a backslash to avoid executing an alias.
|
|
||||||
Some(ShellType::PowerShell) => format!("({command}) | \\Out-Host"),
|
|
||||||
// If we can't determine a shell type, run command as it is.
|
|
||||||
None => command.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn execute(
|
pub(super) fn execute(
|
||||||
&mut self,
|
&mut self,
|
||||||
input: ExecuteActionInput,
|
input: ExecuteActionInput,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) -> impl Into<AnyActionExecution> {
|
) -> impl Into<AnyActionExecution> {
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"[tool-debug] ShellCommandExecutor::execute: action_type={:?}",
|
"ShellCommandExecutor::execute: action_type={:?}",
|
||||||
std::mem::discriminant(&input.action.action)
|
std::mem::discriminant(&input.action.action)
|
||||||
);
|
);
|
||||||
let model = self.terminal_model.lock();
|
let model = self.terminal_model.lock();
|
||||||
@@ -221,17 +218,10 @@ impl ShellCommandExecutor {
|
|||||||
// Determine the action we want to take based on the input.
|
// Determine the action we want to take based on the input.
|
||||||
let action_id = input.action.id.clone();
|
let action_id = input.action.id.clone();
|
||||||
|
|
||||||
let command = model
|
|
||||||
.block_list()
|
|
||||||
.active_block()
|
|
||||||
.command_with_secrets_unobfuscated(false)
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
let handle = ctx.handle();
|
let handle = ctx.handle();
|
||||||
match &input.action.action {
|
match &input.action.action {
|
||||||
AIAgentActionType::RequestCommandOutput {
|
AIAgentActionType::RequestCommandOutput {
|
||||||
command,
|
command,
|
||||||
uses_pager,
|
|
||||||
wait_until_completion,
|
wait_until_completion,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
@@ -240,18 +230,13 @@ impl ShellCommandExecutor {
|
|||||||
.active_block()
|
.active_block()
|
||||||
.is_active_and_long_running()
|
.is_active_and_long_running()
|
||||||
{
|
{
|
||||||
// Another command is still running (e.g. stuck in a pager). Return an error
|
let running_command = model
|
||||||
// result so the model receives feedback and can adapt. Using Completed with a
|
.block_list()
|
||||||
// non-zero exit code ensures a follow-up request is triggered.
|
.active_block()
|
||||||
return ActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
|
.command_with_secrets_unobfuscated(false);
|
||||||
RequestCommandOutputResult::Completed {
|
return ActionExecution::Sync(terminal_busy_execution_error(
|
||||||
command: command.clone(),
|
command,
|
||||||
block_id: model.block_list().active_block().id().clone(),
|
&running_command,
|
||||||
output: "Error: Cannot execute command because another command is still running in the terminal.".to_string(),
|
|
||||||
exit_code: ExitCode::from(1),
|
|
||||||
start_ts: None,
|
|
||||||
completed_ts: None,
|
|
||||||
},
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// If another conversation has taken over the agent view since this command
|
// If another conversation has taken over the agent view since this command
|
||||||
@@ -266,15 +251,13 @@ impl ShellCommandExecutor {
|
|||||||
RequestCommandOutputResult::CancelledBeforeExecution,
|
RequestCommandOutputResult::CancelledBeforeExecution,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// If the command might use pager and can't be interacted with,
|
// A command expected to finish must not enter an implicit pager. Do not trust the
|
||||||
// we pipe its output to cat so we can prevent activating the altscreen.
|
// model-provided pager hint: commands such as `git log` can page implicitly.
|
||||||
// The parentheses here ensures the command always gets evaluated first.
|
let decorated_command = command_for_execution(
|
||||||
let decorated_command =
|
command,
|
||||||
if uses_pager.is_some_and(|uses_pager| uses_pager) && *wait_until_completion {
|
self.active_session.as_ref(ctx).shell_type(ctx),
|
||||||
self.turn_off_pager_for_command(command, ctx)
|
*wait_until_completion,
|
||||||
} else {
|
);
|
||||||
command.clone()
|
|
||||||
};
|
|
||||||
ctx.emit(ShellCommandExecutorEvent::ExecuteCommand {
|
ctx.emit(ShellCommandExecutorEvent::ExecuteCommand {
|
||||||
action_id: action_id.clone(),
|
action_id: action_id.clone(),
|
||||||
command: decorated_command,
|
command: decorated_command,
|
||||||
@@ -295,8 +278,7 @@ impl ShellCommandExecutor {
|
|||||||
// Remove the senders from the maps.
|
// Remove the senders from the maps.
|
||||||
if let Some(handle) = handle.upgrade(ctx) {
|
if let Some(handle) = handle.upgrade(ctx) {
|
||||||
handle.update(ctx, |me, _| {
|
handle.update(ctx, |me, _| {
|
||||||
me.block_finished_senders.remove(&block_selector);
|
me.prune_closed_senders(&block_selector);
|
||||||
me.force_refresh_senders.remove(&block_selector);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,8 +341,7 @@ impl ShellCommandExecutor {
|
|||||||
// Remove the senders from the maps.
|
// Remove the senders from the maps.
|
||||||
if let Some(handle) = handle.upgrade(ctx) {
|
if let Some(handle) = handle.upgrade(ctx) {
|
||||||
handle.update(ctx, |me, _| {
|
handle.update(ctx, |me, _| {
|
||||||
me.block_finished_senders.remove(&block_selector);
|
me.prune_closed_senders(&block_selector);
|
||||||
me.force_refresh_senders.remove(&block_selector);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +372,7 @@ impl ShellCommandExecutor {
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let command = block.command_with_secrets_unobfuscated(false);
|
||||||
drop(model);
|
drop(model);
|
||||||
|
|
||||||
let block_selector = BlockSelector::Id(block_id.clone());
|
let block_selector = BlockSelector::Id(block_id.clone());
|
||||||
@@ -400,8 +382,7 @@ impl ShellCommandExecutor {
|
|||||||
// Remove the senders from the maps.
|
// Remove the senders from the maps.
|
||||||
if let Some(handle) = handle.upgrade(ctx) {
|
if let Some(handle) = handle.upgrade(ctx) {
|
||||||
handle.update(ctx, |me, _| {
|
handle.update(ctx, |me, _| {
|
||||||
me.block_finished_senders.remove(&block_selector);
|
me.prune_closed_senders(&block_selector);
|
||||||
me.force_refresh_senders.remove(&block_selector);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +420,9 @@ impl ShellCommandExecutor {
|
|||||||
// Set up a future to also wait for block completion.
|
// Set up a future to also wait for block completion.
|
||||||
let (block_finished_tx, block_finished_rx) = oneshot::channel();
|
let (block_finished_tx, block_finished_rx) = oneshot::channel();
|
||||||
self.block_finished_senders
|
self.block_finished_senders
|
||||||
.insert(block_selector.clone(), block_finished_tx);
|
.entry(block_selector.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(block_finished_tx);
|
||||||
|
|
||||||
// Build the future that captures terminal model and block data.
|
// Build the future that captures terminal model and block data.
|
||||||
let transfer_future = {
|
let transfer_future = {
|
||||||
@@ -511,7 +494,7 @@ impl ShellCommandExecutor {
|
|||||||
// Clean up.
|
// Clean up.
|
||||||
if let Some(handle) = handle.upgrade(ctx) {
|
if let Some(handle) = handle.upgrade(ctx) {
|
||||||
handle.update(ctx, |me, _| {
|
handle.update(ctx, |me, _| {
|
||||||
me.block_finished_senders.remove(&block_selector);
|
me.prune_closed_senders(&block_selector);
|
||||||
me.control_handback_sender = None;
|
me.control_handback_sender = None;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -540,13 +523,17 @@ impl ShellCommandExecutor {
|
|||||||
// Create a channel to notify us when we receive block metadata.
|
// Create a channel to notify us when we receive block metadata.
|
||||||
let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel();
|
let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel();
|
||||||
self.block_finished_senders
|
self.block_finished_senders
|
||||||
.insert(block_selector.clone(), block_metadata_received_tx);
|
.entry(block_selector.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(block_metadata_received_tx);
|
||||||
|
|
||||||
// Create a channel so `Check now` or the automatic monitor watchdog can short-circuit
|
// Create a channel so `Check now` or the automatic monitor watchdog can short-circuit
|
||||||
// the timeout and deliver the agent a fresh snapshot immediately.
|
// the timeout and deliver the agent a fresh snapshot immediately.
|
||||||
let (force_refresh_tx, force_refresh_rx) = oneshot::channel();
|
let (force_refresh_tx, force_refresh_rx) = oneshot::channel();
|
||||||
self.force_refresh_senders
|
self.force_refresh_senders
|
||||||
.insert(block_selector.clone(), force_refresh_tx);
|
.entry(block_selector.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(force_refresh_tx);
|
||||||
|
|
||||||
// Create a future that resolves when we should send a result to the agent.
|
// Create a future that resolves when we should send a result to the agent.
|
||||||
let terminal_model = self.terminal_model.clone();
|
let terminal_model = self.terminal_model.clone();
|
||||||
@@ -620,7 +607,12 @@ impl ShellCommandExecutor {
|
|||||||
completed_ts: block.completed_ts().cloned(),
|
completed_ts: block.completed_ts().cloned(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let grid_contents = if model.is_alt_screen_active() {
|
let selected_block_owns_alt_screen = selected_block_owns_alt_screen(
|
||||||
|
model.is_alt_screen_active(),
|
||||||
|
model.active_block_id(),
|
||||||
|
block.id(),
|
||||||
|
);
|
||||||
|
let grid_contents = if selected_block_owns_alt_screen {
|
||||||
formatted_terminal_contents_for_input(
|
formatted_terminal_contents_for_input(
|
||||||
model.alt_screen().grid_handler(),
|
model.alt_screen().grid_handler(),
|
||||||
None,
|
None,
|
||||||
@@ -638,7 +630,7 @@ impl ShellCommandExecutor {
|
|||||||
block_id: block.id().clone(),
|
block_id: block.id().clone(),
|
||||||
grid_contents,
|
grid_contents,
|
||||||
cursor: CURSOR_MARKER,
|
cursor: CURSOR_MARKER,
|
||||||
is_alt_screen_active: model.is_alt_screen_active(),
|
is_alt_screen_active: selected_block_owns_alt_screen,
|
||||||
is_preempted,
|
is_preempted,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -650,23 +642,50 @@ impl ShellCommandExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn cancel_execution(&mut self, id: &AIAgentActionId, _ctx: &mut ModelContext<Self>) {
|
pub(super) fn cancel_execution(
|
||||||
|
&mut self,
|
||||||
|
id: &AIAgentActionId,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) -> bool {
|
||||||
let terminal_model = self.terminal_model.lock();
|
let terminal_model = self.terminal_model.lock();
|
||||||
let active_block = terminal_model.block_list().active_block();
|
let requested_selector = BlockSelector::RequestedCommandId(id.clone());
|
||||||
if !active_block.is_active_and_long_running() {
|
let requested_block_is_running = requested_selector
|
||||||
return;
|
.get_block(&terminal_model)
|
||||||
}
|
.is_some_and(|block| block.is_active_and_long_running() && !block.finished());
|
||||||
|
let selector = if requested_block_is_running {
|
||||||
let selector = if active_block
|
requested_selector
|
||||||
.requested_command_action_id()
|
|
||||||
.is_some_and(|requested_command_id| requested_command_id == id)
|
|
||||||
{
|
|
||||||
BlockSelector::RequestedCommandId(id.clone())
|
|
||||||
} else {
|
} else {
|
||||||
BlockSelector::Id(active_block.id().clone())
|
BlockSelector::Id(terminal_model.active_block_id().clone())
|
||||||
};
|
};
|
||||||
self.block_finished_senders.remove(&selector);
|
// Cancelling the wait future alone would report cancellation while the process keeps
|
||||||
self.force_refresh_senders.remove(&selector);
|
// running. Terminate the exact requested command before resolving the action as cancelled.
|
||||||
|
if requested_block_is_running {
|
||||||
|
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
|
||||||
|
action_id: id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !requested_block_is_running {
|
||||||
|
self.block_finished_senders.remove(&selector);
|
||||||
|
self.force_refresh_senders.remove(&selector);
|
||||||
|
}
|
||||||
|
requested_block_is_running
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_closed_senders(&mut self, selector: &BlockSelector) {
|
||||||
|
Self::prune_closed_sender_group(&mut self.block_finished_senders, selector);
|
||||||
|
Self::prune_closed_sender_group(&mut self.force_refresh_senders, selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_closed_sender_group(
|
||||||
|
senders: &mut HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
|
||||||
|
selector: &BlockSelector,
|
||||||
|
) {
|
||||||
|
if let Some(selector_senders) = senders.get_mut(selector) {
|
||||||
|
selector_senders.retain(|sender| !sender.is_canceled());
|
||||||
|
if selector_senders.is_empty() {
|
||||||
|
senders.remove(selector);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force any in-flight poll for the given long-running command block to resolve
|
/// Force any in-flight poll for the given long-running command block to resolve
|
||||||
@@ -677,23 +696,28 @@ impl ShellCommandExecutor {
|
|||||||
/// control to the user). Returns whether a matching poll was successfully refreshed.
|
/// control to the user). Returns whether a matching poll was successfully refreshed.
|
||||||
pub fn force_refresh_block(&mut self, block_id: &BlockId) -> bool {
|
pub fn force_refresh_block(&mut self, block_id: &BlockId) -> bool {
|
||||||
let terminal_model = self.terminal_model.lock();
|
let terminal_model = self.terminal_model.lock();
|
||||||
// Find a sender whose selector resolves to this block. In practice there is at
|
// Find every pending poll whose selector resolves to this block. Multiple provider polls
|
||||||
// most one: a given block can have at most one in-flight `action_result_future`
|
// may legitimately wait on the same command and must be refreshed together.
|
||||||
// at a time.
|
|
||||||
let matching_selector = self
|
let matching_selector = self
|
||||||
.force_refresh_senders
|
.force_refresh_senders
|
||||||
.keys()
|
.keys()
|
||||||
.find(|selector| {
|
.find(|selector| {
|
||||||
selector
|
selector.get_block(&terminal_model).is_some_and(|block| {
|
||||||
.get_block(&terminal_model)
|
block.id() == block_id
|
||||||
.is_some_and(|block| block.id() == block_id)
|
&& block.is_active_and_long_running()
|
||||||
|
&& !block.finished()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.cloned();
|
.cloned();
|
||||||
drop(terminal_model);
|
drop(terminal_model);
|
||||||
|
|
||||||
if let Some(selector) = matching_selector {
|
if let Some(selector) = matching_selector {
|
||||||
if let Some(sender) = self.force_refresh_senders.remove(&selector) {
|
if let Some(senders) = self.force_refresh_senders.remove(&selector) {
|
||||||
return sender.send(()).is_ok();
|
let mut refreshed = false;
|
||||||
|
for sender in senders {
|
||||||
|
refreshed |= sender.send(()).is_ok();
|
||||||
|
}
|
||||||
|
return refreshed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
@@ -708,6 +732,45 @@ impl ShellCommandExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn command_for_execution(
|
||||||
|
command: &str,
|
||||||
|
shell_type: Option<ShellType>,
|
||||||
|
wait_until_completion: bool,
|
||||||
|
) -> String {
|
||||||
|
if !wait_until_completion {
|
||||||
|
return command.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
match shell_type {
|
||||||
|
// Pager environment variables preserve the command's output and exit status, unlike piping
|
||||||
|
// through `cat`. Tool-specific variables override user configuration for common pagers.
|
||||||
|
Some(ShellType::Zsh) | Some(ShellType::Bash) => format!(
|
||||||
|
"(export PAGER=cat GIT_PAGER=cat GH_PAGER=cat AWS_PAGER=cat SYSTEMD_PAGER=cat; {command})"
|
||||||
|
),
|
||||||
|
Some(ShellType::Fish) => format!(
|
||||||
|
"begin; set -lx PAGER cat; set -lx GIT_PAGER cat; set -lx GH_PAGER cat; set -lx AWS_PAGER cat; set -lx SYSTEMD_PAGER cat; {command}; end"
|
||||||
|
),
|
||||||
|
// PowerShell's pipeline host suppresses paging for commands that honor the host stream.
|
||||||
|
Some(ShellType::PowerShell) => format!("({command}) | \\Out-Host"),
|
||||||
|
None => command.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_busy_execution_error(command: &str, running_command: &str) -> AIAgentActionResultType {
|
||||||
|
AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::ExecutionError {
|
||||||
|
command: command.to_string(),
|
||||||
|
message: format!("terminal is busy running command '{running_command}'"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_block_owns_alt_screen(
|
||||||
|
is_alt_screen_active: bool,
|
||||||
|
active_block_id: &BlockId,
|
||||||
|
selected_block_id: &BlockId,
|
||||||
|
) -> bool {
|
||||||
|
is_alt_screen_active && active_block_id == selected_block_id
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||||
enum BlockSelector {
|
enum BlockSelector {
|
||||||
Id(BlockId),
|
Id(BlockId),
|
||||||
@@ -913,7 +976,9 @@ pub enum ShellCommandExecutorEvent {
|
|||||||
input: Bytes,
|
input: Bytes,
|
||||||
mode: AIAgentPtyWriteMode,
|
mode: AIAgentPtyWriteMode,
|
||||||
},
|
},
|
||||||
CancelExecution,
|
CancelExecution {
|
||||||
|
action_id: AIAgentActionId,
|
||||||
|
},
|
||||||
/// Emitted when the agent requests to transfer control of a long-running command to the user.
|
/// Emitted when the agent requests to transfer control of a long-running command to the user.
|
||||||
TransferControlToUser {
|
TransferControlToUser {
|
||||||
action_id: AIAgentActionId,
|
action_id: AIAgentActionId,
|
||||||
|
|||||||
@@ -1,18 +1,80 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::task::Poll;
|
||||||
|
|
||||||
use async_channel::unbounded;
|
use async_channel::unbounded;
|
||||||
use futures::channel::oneshot;
|
use futures::channel::oneshot;
|
||||||
|
use futures::{pin_mut, poll};
|
||||||
use parking_lot::FairMutex;
|
use parking_lot::FairMutex;
|
||||||
use warpui::{App, EntityId};
|
use warpui::{App, EntityId};
|
||||||
|
|
||||||
use super::{ActionResult, BlockSelector, ShellCommandExecutor};
|
use super::{
|
||||||
use crate::ai::agent::ShellCommandDelay;
|
command_for_execution, selected_block_owns_alt_screen, terminal_busy_execution_error,
|
||||||
use crate::terminal::event::{BlockMetadataReceivedEvent, BlockWorkingDirectoryUpdatedEvent};
|
ActionResult, BlockSelector, ShellCommandExecutor,
|
||||||
|
};
|
||||||
|
use crate::ai::agent::{
|
||||||
|
AIAgentActionId, AIAgentActionResultType, RequestCommandOutputResult, ShellCommandDelay,
|
||||||
|
};
|
||||||
|
use crate::terminal::event::{
|
||||||
|
BlockCompletedEvent, BlockMetadataReceivedEvent, BlockType, BlockWorkingDirectoryUpdatedEvent,
|
||||||
|
};
|
||||||
use crate::terminal::model::block::{BlockId, BlockMetadata};
|
use crate::terminal::model::block::{BlockId, BlockMetadata};
|
||||||
use crate::terminal::model::session::active_session::ActiveSession;
|
use crate::terminal::model::session::active_session::ActiveSession;
|
||||||
use crate::terminal::model::session::Sessions;
|
use crate::terminal::model::session::Sessions;
|
||||||
use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel};
|
use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel};
|
||||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||||
|
use crate::terminal::shell::ShellType;
|
||||||
|
use crate::AIConversationId;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wait_commands_disable_implicit_posix_pagers_without_masking_exit_status() {
|
||||||
|
let command = "git log -8 --oneline && false";
|
||||||
|
let decorated = command_for_execution(command, Some(ShellType::Zsh), true);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
decorated,
|
||||||
|
"(export PAGER=cat GIT_PAGER=cat GH_PAGER=cat AWS_PAGER=cat SYSTEMD_PAGER=cat; git log -8 --oneline && false)"
|
||||||
|
);
|
||||||
|
assert!(!decorated.contains("| command cat"));
|
||||||
|
assert_eq!(
|
||||||
|
command_for_execution(command, Some(ShellType::Zsh), false),
|
||||||
|
command
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_busy_is_an_execution_error_for_the_unstarted_command() {
|
||||||
|
let result = terminal_busy_execution_error("cargo test", "sleep 120");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
AIAgentActionResultType::RequestCommandOutput(
|
||||||
|
RequestCommandOutputResult::ExecutionError { command, message }
|
||||||
|
) if command == "cargo test"
|
||||||
|
&& message == "terminal is busy running command 'sleep 120'"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn targeted_poll_uses_alt_screen_only_for_its_owning_block() {
|
||||||
|
let active_block_id = BlockId::new();
|
||||||
|
let selected_block_id = BlockId::new();
|
||||||
|
|
||||||
|
assert!(!selected_block_owns_alt_screen(
|
||||||
|
true,
|
||||||
|
&active_block_id,
|
||||||
|
&selected_block_id
|
||||||
|
));
|
||||||
|
assert!(selected_block_owns_alt_screen(
|
||||||
|
true,
|
||||||
|
&active_block_id,
|
||||||
|
&active_block_id
|
||||||
|
));
|
||||||
|
assert!(!selected_block_owns_alt_screen(
|
||||||
|
false,
|
||||||
|
&active_block_id,
|
||||||
|
&active_block_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// Locks in the contract that `ShellCommandExecutor`'s requested-command finish
|
/// Locks in the contract that `ShellCommandExecutor`'s requested-command finish
|
||||||
/// detector reacts only to `BlockMetadataReceived` (precmd) and not to
|
/// detector reacts only to `BlockMetadataReceived` (precmd) and not to
|
||||||
@@ -46,7 +108,7 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
|
|||||||
let selector = BlockSelector::Id(block_id);
|
let selector = BlockSelector::Id(block_id);
|
||||||
let (tx, _rx) = oneshot::channel::<()>();
|
let (tx, _rx) = oneshot::channel::<()>();
|
||||||
executor.update(&mut app, |executor, _ctx| {
|
executor.update(&mut app, |executor, _ctx| {
|
||||||
executor.block_finished_senders.insert(selector, tx);
|
executor.block_finished_senders.insert(selector, vec![tx]);
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
|
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
|
||||||
@@ -71,8 +133,7 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
|
|||||||
that map is reserved for precmd (BlockMetadataReceived)"
|
that map is reserved for precmd (BlockMetadataReceived)"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Precmd event — the senders map should be drained (and since the
|
// An unrelated precmd cannot resolve this selector, so its waiter must survive.
|
||||||
// block isn't in the terminal model, the sender is dropped).
|
|
||||||
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
||||||
ctx.emit(ModelEvent::BlockMetadataReceived(
|
ctx.emit(ModelEvent::BlockMetadataReceived(
|
||||||
BlockMetadataReceivedEvent {
|
BlockMetadataReceivedEvent {
|
||||||
@@ -85,8 +146,8 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
|
|||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
|
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
|
||||||
0,
|
1,
|
||||||
"BlockMetadataReceived should drain the finish senders"
|
"BlockMetadataReceived must retain unresolved finish senders"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,11 +164,14 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
|||||||
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
||||||
});
|
});
|
||||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||||
|
terminal_model
|
||||||
|
.lock()
|
||||||
|
.simulate_long_running_block("sleep 120", "still running");
|
||||||
let block_id = terminal_model.lock().active_block_id().clone();
|
let block_id = terminal_model.lock().active_block_id().clone();
|
||||||
let executor = app.add_model(|ctx| {
|
let executor = app.add_model(|ctx| {
|
||||||
ShellCommandExecutor::new(
|
ShellCommandExecutor::new(
|
||||||
active_session,
|
active_session,
|
||||||
terminal_model,
|
terminal_model.clone(),
|
||||||
&model_event_dispatcher,
|
&model_event_dispatcher,
|
||||||
terminal_view_id,
|
terminal_view_id,
|
||||||
ctx,
|
ctx,
|
||||||
@@ -118,15 +182,170 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
|||||||
executor.update(&mut app, |executor, _| {
|
executor.update(&mut app, |executor, _| {
|
||||||
executor
|
executor
|
||||||
.force_refresh_senders
|
.force_refresh_senders
|
||||||
.insert(BlockSelector::Id(block_id.clone()), tx);
|
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
|
||||||
assert!(executor.force_refresh_block(&block_id));
|
assert!(executor.force_refresh_block(&block_id));
|
||||||
assert!(!executor.force_refresh_block(&block_id));
|
assert!(!executor.force_refresh_block(&block_id));
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(matches!(rx.try_recv(), Ok(Some(()))));
|
assert!(matches!(rx.try_recv(), Ok(Some(()))));
|
||||||
|
|
||||||
|
let (tx, _rx) = oneshot::channel();
|
||||||
|
executor.update(&mut app, |executor, _| {
|
||||||
|
executor
|
||||||
|
.force_refresh_senders
|
||||||
|
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
|
||||||
|
});
|
||||||
|
terminal_model.lock().finish_block();
|
||||||
|
assert!(executor.update(&mut app, |executor, _| {
|
||||||
|
!executor.force_refresh_block(&block_id)
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requested_command_waiter_survives_early_metadata_and_resolves_after_association() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||||
|
let (_model_events_tx, model_events_rx) = unbounded();
|
||||||
|
let model_event_dispatcher =
|
||||||
|
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
|
||||||
|
let active_session = app.add_model(|ctx| {
|
||||||
|
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
||||||
|
});
|
||||||
|
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||||
|
let executor = app.add_model(|ctx| {
|
||||||
|
ShellCommandExecutor::new(
|
||||||
|
active_session,
|
||||||
|
terminal_model.clone(),
|
||||||
|
&model_event_dispatcher,
|
||||||
|
terminal_view_id,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let action_id = AIAgentActionId::from("requested-command".to_string());
|
||||||
|
let result_future = executor.update(&mut app, |executor, _| {
|
||||||
|
executor.action_result_future(
|
||||||
|
BlockSelector::RequestedCommandId(action_id.clone()),
|
||||||
|
Some(ShellCommandDelay::OnCompletion),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
pin_mut!(result_future);
|
||||||
|
|
||||||
|
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
||||||
|
ctx.emit(ModelEvent::BlockMetadataReceived(
|
||||||
|
BlockMetadataReceivedEvent {
|
||||||
|
block_metadata: BlockMetadata::new(None, Some("/tmp/early".to_string())),
|
||||||
|
block_index: BlockIndex::zero(),
|
||||||
|
is_after_in_band_command: false,
|
||||||
|
is_done_bootstrapping: true,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
});
|
||||||
|
assert!(matches!(poll!(&mut result_future), Poll::Pending));
|
||||||
|
|
||||||
|
terminal_model
|
||||||
|
.lock()
|
||||||
|
.simulate_long_running_block("printf done", "done");
|
||||||
|
let block_id = terminal_model.lock().active_block_id().clone();
|
||||||
|
terminal_model
|
||||||
|
.lock()
|
||||||
|
.block_list_mut()
|
||||||
|
.active_block_mut()
|
||||||
|
.set_agent_interaction_mode_for_requested_command(
|
||||||
|
action_id,
|
||||||
|
None,
|
||||||
|
AIConversationId::new(),
|
||||||
|
);
|
||||||
|
terminal_model.lock().finish_block();
|
||||||
|
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
||||||
|
ctx.emit(ModelEvent::BlockCompleted(block_completed_event(
|
||||||
|
block_id.clone(),
|
||||||
|
)));
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
result_future.await,
|
||||||
|
ActionResult::CommandFinished {
|
||||||
|
block_id: result_block_id,
|
||||||
|
..
|
||||||
|
} if result_block_id == block_id
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_completion_polls_for_same_block_both_resolve_on_block_completed() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||||
|
let (_model_events_tx, model_events_rx) = unbounded();
|
||||||
|
let model_event_dispatcher =
|
||||||
|
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
|
||||||
|
let active_session = app.add_model(|ctx| {
|
||||||
|
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
||||||
|
});
|
||||||
|
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||||
|
terminal_model
|
||||||
|
.lock()
|
||||||
|
.simulate_long_running_block("sleep 1", "finished");
|
||||||
|
let block_id = terminal_model.lock().active_block_id().clone();
|
||||||
|
let executor = app.add_model(|ctx| {
|
||||||
|
ShellCommandExecutor::new(
|
||||||
|
active_session,
|
||||||
|
terminal_model.clone(),
|
||||||
|
&model_event_dispatcher,
|
||||||
|
terminal_view_id,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let first = executor.update(&mut app, |executor, _| {
|
||||||
|
executor.action_result_future(
|
||||||
|
BlockSelector::Id(block_id.clone()),
|
||||||
|
Some(ShellCommandDelay::OnCompletion),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let second = executor.update(&mut app, |executor, _| {
|
||||||
|
executor.action_result_future(
|
||||||
|
BlockSelector::Id(block_id.clone()),
|
||||||
|
Some(ShellCommandDelay::OnCompletion),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
pin_mut!(first);
|
||||||
|
pin_mut!(second);
|
||||||
|
assert!(matches!(poll!(&mut first), Poll::Pending));
|
||||||
|
assert!(matches!(poll!(&mut second), Poll::Pending));
|
||||||
|
|
||||||
|
terminal_model.lock().finish_block();
|
||||||
|
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
||||||
|
ctx.emit(ModelEvent::BlockCompleted(block_completed_event(
|
||||||
|
block_id.clone(),
|
||||||
|
)));
|
||||||
|
});
|
||||||
|
|
||||||
|
let first_result = first.await;
|
||||||
|
let second_result = second.await;
|
||||||
|
assert!(matches!(first_result, ActionResult::CommandFinished { .. }));
|
||||||
|
assert!(matches!(
|
||||||
|
second_result,
|
||||||
|
ActionResult::CommandFinished { .. }
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_completed_event(block_id: BlockId) -> BlockCompletedEvent {
|
||||||
|
BlockCompletedEvent {
|
||||||
|
block_latency_data: None,
|
||||||
|
block_type: BlockType::Restored,
|
||||||
|
num_secrets_obfuscated: 0,
|
||||||
|
block_index: BlockIndex::zero(),
|
||||||
|
block_id,
|
||||||
|
session_id: None,
|
||||||
|
restored_block_was_local: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() {
|
fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use futures::future::BoxFuture;
|
use futures::future::BoxFuture;
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
@@ -6,7 +8,10 @@ use galaxy_cli::agent::Harness;
|
|||||||
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||||
use shell_words::split as split_shell_words;
|
use shell_words::split as split_shell_words;
|
||||||
|
|
||||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
use super::{
|
||||||
|
child_agent_delegation_denial_reason, compose_leaf_agent_prompt, ActionExecution,
|
||||||
|
AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
|
||||||
|
};
|
||||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
|
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
|
||||||
use crate::ai::agent::{
|
use crate::ai::agent::{
|
||||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
|
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
|
||||||
@@ -27,10 +32,38 @@ pub enum StartAgentOutcome {
|
|||||||
agent_id: String,
|
agent_id: String,
|
||||||
output: String,
|
output: String,
|
||||||
},
|
},
|
||||||
/// An error occurred while starting the agent.
|
/// An error occurred while starting or running the agent.
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determines whether a dispatch receiver acknowledges startup or waits for a
|
||||||
|
/// direct-provider child to reach a terminal state.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum StartAgentWaitPolicy {
|
||||||
|
Startup,
|
||||||
|
Completion,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_policy_for_execution_mode(mode: &StartAgentExecutionMode) -> StartAgentWaitPolicy {
|
||||||
|
match mode {
|
||||||
|
StartAgentExecutionMode::Local { .. } => StartAgentWaitPolicy::Completion,
|
||||||
|
StartAgentExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StartAgentDispatch {
|
||||||
|
pub request_id: StartAgentRequestId,
|
||||||
|
pub receiver: async_channel::Receiver<StartAgentOutcome>,
|
||||||
|
pub wait_policy: StartAgentWaitPolicy,
|
||||||
|
pub(super) detached: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StartAgentDispatch {
|
||||||
|
pub(super) fn mark_detached(&self) {
|
||||||
|
self.detached.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn invalid_local_child_harness_error(harness_type: &str) -> String {
|
fn invalid_local_child_harness_error(harness_type: &str) -> String {
|
||||||
let harness_name = harness_type.trim();
|
let harness_name = harness_type.trim();
|
||||||
if harness_name.is_empty() {
|
if harness_name.is_empty() {
|
||||||
@@ -115,17 +148,19 @@ pub struct StartAgentRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct PendingStartAgent {
|
struct PendingStartAgent {
|
||||||
/// Present for standalone StartAgent tool calls. RunAgents dispatches use
|
action_id: AIAgentActionId,
|
||||||
/// the same executor but do not have a one-to-one StartAgent action card.
|
/// Present when RunAgents owns this dispatch. Standalone StartAgent calls
|
||||||
action_id: Option<AIAgentActionId>,
|
/// use the action id only for their one-to-one inline child panel.
|
||||||
|
run_agents_child_name: Option<String>,
|
||||||
parent_conversation_id: AIConversationId,
|
parent_conversation_id: AIConversationId,
|
||||||
/// Set once the child conversation is synchronously created.
|
/// Set once the child conversation is synchronously created.
|
||||||
child_conversation_id: Option<AIConversationId>,
|
child_conversation_id: Option<AIConversationId>,
|
||||||
sender: async_channel::Sender<StartAgentOutcome>,
|
sender: async_channel::Sender<StartAgentOutcome>,
|
||||||
|
detached: Arc<AtomicBool>,
|
||||||
/// Direct Bedrock/OpenAI parents do not have a server run id or an
|
/// Direct Bedrock/OpenAI parents do not have a server run id or an
|
||||||
/// orchestration event stream. Keep the tool call open until their local
|
/// orchestration event stream. Keep the tool call open until their local
|
||||||
/// child finishes, then return the child's output inline.
|
/// child finishes, then return the child's output inline.
|
||||||
wait_for_completion: bool,
|
wait_policy: StartAgentWaitPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StartAgentExecutor {
|
pub struct StartAgentExecutor {
|
||||||
@@ -158,34 +193,41 @@ impl StartAgentExecutor {
|
|||||||
child_conversation_id: AIConversationId,
|
child_conversation_id: AIConversationId,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
let direct_provider_panel_link = {
|
let child_link_event = {
|
||||||
let Some(pending) = self.pending.get_mut(&request_id) else {
|
let Some(pending) = self.pending.get(&request_id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if pending.detached.load(Ordering::Acquire) {
|
||||||
|
self.pending.remove(&request_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pending = self
|
||||||
|
.pending
|
||||||
|
.get_mut(&request_id)
|
||||||
|
.expect("pending request was checked above");
|
||||||
pending.child_conversation_id = Some(child_conversation_id);
|
pending.child_conversation_id = Some(child_conversation_id);
|
||||||
if pending.wait_for_completion {
|
if let Some(agent_name) = pending.run_agents_child_name.clone() {
|
||||||
pending.action_id.clone().map(|action_id| {
|
Some(StartAgentExecutorEvent::RunAgentsChildConversationCreated {
|
||||||
(
|
action_id: pending.action_id.clone(),
|
||||||
action_id,
|
agent_name,
|
||||||
pending.parent_conversation_id,
|
parent_conversation_id: pending.parent_conversation_id,
|
||||||
child_conversation_id,
|
child_conversation_id,
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
} else if matches!(pending.wait_policy, StartAgentWaitPolicy::Completion) {
|
||||||
|
Some(
|
||||||
|
StartAgentExecutorEvent::DirectProviderChildConversationCreated {
|
||||||
|
action_id: pending.action_id.clone(),
|
||||||
|
parent_conversation_id: pending.parent_conversation_id,
|
||||||
|
child_conversation_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some((action_id, parent_conversation_id, child_conversation_id)) =
|
if let Some(event) = child_link_event {
|
||||||
direct_provider_panel_link
|
ctx.emit(event);
|
||||||
{
|
|
||||||
ctx.emit(
|
|
||||||
StartAgentExecutorEvent::DirectProviderChildConversationCreated {
|
|
||||||
action_id,
|
|
||||||
parent_conversation_id,
|
|
||||||
child_conversation_id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx);
|
self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx);
|
||||||
}
|
}
|
||||||
@@ -271,14 +313,15 @@ impl StartAgentExecutor {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg));
|
let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg));
|
||||||
// A child that reaches `complete_pending_as_error` never obtained an
|
// Only startup acknowledgements may clean up a conversation that never
|
||||||
// agent id, so it failed at the launch stage. Clean up its hidden
|
// initialized. Direct-provider completion waits preserve the terminal
|
||||||
// pane + conversation so the orchestration pill bar does not retain a
|
// child so its transcript and failure remain inspectable.
|
||||||
// dead chip — but only for terminal failures, leaving recoverable
|
let should_cleanup = matches!(pending.wait_policy, StartAgentWaitPolicy::Startup)
|
||||||
// `Blocked` startup states (e.g. awaiting GitHub auth) intact.
|
&& BlocklistAIHistoryModel::as_ref(ctx)
|
||||||
let should_cleanup = BlocklistAIHistoryModel::as_ref(ctx)
|
.conversation(&child_conversation_id)
|
||||||
.conversation(&child_conversation_id)
|
.is_some_and(|conversation| {
|
||||||
.is_some_and(|conversation| should_cleanup_failed_child_launch(conversation.status()));
|
should_cleanup_failed_child_launch(conversation.status())
|
||||||
|
});
|
||||||
if should_cleanup {
|
if should_cleanup {
|
||||||
ctx.emit(StartAgentExecutorEvent::CleanupFailedChildLaunch {
|
ctx.emit(StartAgentExecutorEvent::CleanupFailedChildLaunch {
|
||||||
conversation_id: child_conversation_id,
|
conversation_id: child_conversation_id,
|
||||||
@@ -297,23 +340,49 @@ impl StartAgentExecutor {
|
|||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Some(error_msg) = start_agent_error_message_for_status(
|
let wait_policy = self
|
||||||
conversation.status(),
|
|
||||||
conversation.status_error_message().as_deref(),
|
|
||||||
) {
|
|
||||||
self.complete_pending_as_error(request_id, child_conversation_id, error_msg, ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let wait_for_completion = self
|
|
||||||
.pending
|
.pending
|
||||||
.get(&request_id)
|
.get(&request_id)
|
||||||
.is_some_and(|pending| pending.wait_for_completion);
|
.map(|pending| pending.wait_policy);
|
||||||
if wait_for_completion && matches!(conversation.status(), ConversationStatus::Success) {
|
match wait_policy {
|
||||||
self.complete_pending_as_completed(request_id, child_conversation_id, ctx);
|
Some(StartAgentWaitPolicy::Completion) => match conversation.status() {
|
||||||
return;
|
ConversationStatus::Success => {
|
||||||
}
|
self.complete_pending_as_completed(request_id, child_conversation_id, ctx);
|
||||||
if conversation.orchestration_agent_id().is_some() {
|
}
|
||||||
self.complete_pending_as_started(request_id, child_conversation_id, ctx);
|
ConversationStatus::Error | ConversationStatus::Cancelled => {
|
||||||
|
let error_msg = direct_child_error_message_for_status(
|
||||||
|
conversation.status(),
|
||||||
|
conversation.status_error_message().as_deref(),
|
||||||
|
)
|
||||||
|
.expect("terminal direct child status should produce an error");
|
||||||
|
self.complete_pending_as_error(
|
||||||
|
request_id,
|
||||||
|
child_conversation_id,
|
||||||
|
error_msg,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ConversationStatus::InProgress
|
||||||
|
| ConversationStatus::TransientError
|
||||||
|
| ConversationStatus::Blocked { .. }
|
||||||
|
| ConversationStatus::WaitingForEvents => {}
|
||||||
|
},
|
||||||
|
Some(StartAgentWaitPolicy::Startup) => {
|
||||||
|
if let Some(error_msg) = start_agent_startup_error_message_for_status(
|
||||||
|
conversation.status(),
|
||||||
|
conversation.status_error_message().as_deref(),
|
||||||
|
) {
|
||||||
|
self.complete_pending_as_error(
|
||||||
|
request_id,
|
||||||
|
child_conversation_id,
|
||||||
|
error_msg,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
} else if conversation.orchestration_agent_id().is_some() {
|
||||||
|
self.complete_pending_as_started(request_id, child_conversation_id, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +415,22 @@ impl StartAgentExecutor {
|
|||||||
} => {
|
} => {
|
||||||
self.record_child_conversation(*request_id, *conversation_id, ctx);
|
self.record_child_conversation(*request_id, *conversation_id, ctx);
|
||||||
}
|
}
|
||||||
|
BlocklistAIHistoryEvent::RemoveConversation {
|
||||||
|
conversation_id, ..
|
||||||
|
}
|
||||||
|
| BlocklistAIHistoryEvent::DeletedConversation {
|
||||||
|
conversation_id, ..
|
||||||
|
} => {
|
||||||
|
let Some(request_id) = self.find_pending_by_child(conversation_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(pending) = self.pending.remove(&request_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = pending.sender.try_send(StartAgentOutcome::Error(
|
||||||
|
"Child agent conversation was removed by the user.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
BlocklistAIHistoryEvent::StartedNewConversation { .. }
|
BlocklistAIHistoryEvent::StartedNewConversation { .. }
|
||||||
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
|
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
|
||||||
| BlocklistAIHistoryEvent::UpgradedTask { .. }
|
| BlocklistAIHistoryEvent::UpgradedTask { .. }
|
||||||
@@ -358,8 +443,6 @@ impl StartAgentExecutor {
|
|||||||
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
|
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
|
||||||
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
|
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
|
||||||
| BlocklistAIHistoryEvent::SplitConversation { .. }
|
| BlocklistAIHistoryEvent::SplitConversation { .. }
|
||||||
| BlocklistAIHistoryEvent::RemoveConversation { .. }
|
|
||||||
| BlocklistAIHistoryEvent::DeletedConversation { .. }
|
|
||||||
| BlocklistAIHistoryEvent::RestoredConversations { .. }
|
| BlocklistAIHistoryEvent::RestoredConversations { .. }
|
||||||
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
|
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
|
||||||
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
|
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
|
||||||
@@ -400,12 +483,19 @@ impl StartAgentExecutor {
|
|||||||
return ActionExecution::InvalidAction;
|
return ActionExecution::InvalidAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
let prompt = prompt.clone();
|
|
||||||
let version = *version;
|
let version = *version;
|
||||||
let action_id = input.action.id.clone();
|
|
||||||
let parent_conversation_id = input.conversation_id;
|
let parent_conversation_id = input.conversation_id;
|
||||||
|
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
|
||||||
|
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
|
||||||
|
StartAgentResult::Error { error, version },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = prompt.clone();
|
||||||
|
let action_id = input.action.id.clone();
|
||||||
let (prompt, execution_mode) =
|
let (prompt, execution_mode) =
|
||||||
normalize_legacy_local_child_harness_command(prompt, execution_mode.clone());
|
normalize_legacy_local_child_harness_command(prompt, execution_mode.clone());
|
||||||
|
let prompt = compose_leaf_agent_prompt(&prompt);
|
||||||
let (execution_mode, parent_run_id) = match execution_mode {
|
let (execution_mode, parent_run_id) = match execution_mode {
|
||||||
StartAgentExecutionMode::Local {
|
StartAgentExecutionMode::Local {
|
||||||
harness_type: None,
|
harness_type: None,
|
||||||
@@ -531,20 +621,23 @@ impl StartAgentExecutor {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// In local mode (no parent_run_id), block until the child finishes
|
// Local children return their completed work; remote children acknowledge startup and
|
||||||
// so the parent model receives the child's output as the tool result.
|
// continue through the hosted orchestration lifecycle.
|
||||||
let wait_for_completion = parent_run_id.is_none();
|
let wait_policy = wait_policy_for_execution_mode(&execution_mode);
|
||||||
|
|
||||||
let (sender, receiver) = async_channel::bounded(1);
|
let (sender, receiver) = async_channel::bounded(1);
|
||||||
let request_id = self.next_request_id();
|
let request_id = self.next_request_id();
|
||||||
|
let detached = Arc::new(AtomicBool::new(false));
|
||||||
self.pending.insert(
|
self.pending.insert(
|
||||||
request_id,
|
request_id,
|
||||||
PendingStartAgent {
|
PendingStartAgent {
|
||||||
action_id: Some(action_id),
|
action_id,
|
||||||
|
run_agents_child_name: None,
|
||||||
parent_conversation_id,
|
parent_conversation_id,
|
||||||
child_conversation_id: None,
|
child_conversation_id: None,
|
||||||
sender,
|
sender,
|
||||||
wait_for_completion,
|
detached,
|
||||||
|
wait_policy,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -589,6 +682,7 @@ impl StartAgentExecutor {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn dispatch(
|
pub fn dispatch(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
action_id: AIAgentActionId,
|
||||||
name: String,
|
name: String,
|
||||||
prompt: String,
|
prompt: String,
|
||||||
execution_mode: StartAgentExecutionMode,
|
execution_mode: StartAgentExecutionMode,
|
||||||
@@ -596,19 +690,34 @@ impl StartAgentExecutor {
|
|||||||
parent_conversation_id: AIConversationId,
|
parent_conversation_id: AIConversationId,
|
||||||
parent_run_id: Option<String>,
|
parent_run_id: Option<String>,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) -> async_channel::Receiver<StartAgentOutcome> {
|
) -> StartAgentDispatch {
|
||||||
let (prompt, execution_mode) =
|
let wait_policy = wait_policy_for_execution_mode(&execution_mode);
|
||||||
normalize_legacy_local_child_harness_command(prompt, execution_mode);
|
|
||||||
let (sender, receiver) = async_channel::bounded(1);
|
let (sender, receiver) = async_channel::bounded(1);
|
||||||
let request_id = self.next_request_id();
|
let request_id = self.next_request_id();
|
||||||
|
let detached = Arc::new(AtomicBool::new(false));
|
||||||
|
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
|
||||||
|
let _ = sender.try_send(StartAgentOutcome::Error(error));
|
||||||
|
return StartAgentDispatch {
|
||||||
|
request_id,
|
||||||
|
receiver,
|
||||||
|
wait_policy,
|
||||||
|
detached,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let (prompt, execution_mode) =
|
||||||
|
normalize_legacy_local_child_harness_command(prompt, execution_mode);
|
||||||
|
let prompt = compose_leaf_agent_prompt(&prompt);
|
||||||
self.pending.insert(
|
self.pending.insert(
|
||||||
request_id,
|
request_id,
|
||||||
PendingStartAgent {
|
PendingStartAgent {
|
||||||
action_id: None,
|
action_id,
|
||||||
|
run_agents_child_name: Some(name.clone()),
|
||||||
parent_conversation_id,
|
parent_conversation_id,
|
||||||
child_conversation_id: None,
|
child_conversation_id: None,
|
||||||
sender,
|
sender,
|
||||||
wait_for_completion: parent_run_id.is_none(),
|
detached: detached.clone(),
|
||||||
|
wait_policy,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
|
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
|
||||||
@@ -622,7 +731,92 @@ impl StartAgentExecutor {
|
|||||||
parent_run_id,
|
parent_run_id,
|
||||||
},
|
},
|
||||||
)));
|
)));
|
||||||
receiver
|
StartAgentDispatch {
|
||||||
|
request_id,
|
||||||
|
receiver,
|
||||||
|
wait_policy,
|
||||||
|
detached,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reattach(
|
||||||
|
&mut self,
|
||||||
|
action_id: AIAgentActionId,
|
||||||
|
name: String,
|
||||||
|
parent_conversation_id: AIConversationId,
|
||||||
|
child_conversation_id: AIConversationId,
|
||||||
|
wait_policy: StartAgentWaitPolicy,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) -> StartAgentDispatch {
|
||||||
|
let (sender, receiver) = async_channel::bounded(1);
|
||||||
|
let request_id = self.next_request_id();
|
||||||
|
let detached = Arc::new(AtomicBool::new(false));
|
||||||
|
self.pending.insert(
|
||||||
|
request_id,
|
||||||
|
PendingStartAgent {
|
||||||
|
action_id,
|
||||||
|
run_agents_child_name: Some(name),
|
||||||
|
parent_conversation_id,
|
||||||
|
child_conversation_id: Some(child_conversation_id),
|
||||||
|
sender,
|
||||||
|
detached: detached.clone(),
|
||||||
|
wait_policy,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.record_child_conversation(request_id, child_conversation_id, ctx);
|
||||||
|
StartAgentDispatch {
|
||||||
|
request_id,
|
||||||
|
receiver,
|
||||||
|
wait_policy,
|
||||||
|
detached,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detaches one exact dispatch. If its launch callback is already queued,
|
||||||
|
/// the shared marker prevents that callback from linking a late child.
|
||||||
|
pub fn detach_dispatch(&mut self, request_id: StartAgentRequestId) -> bool {
|
||||||
|
let Some(pending) = self.pending.remove(&request_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
pending.detached.store(true, Ordering::Release);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only lookup for request ownership without exposing executor internals.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn has_pending_dispatch_for_test(&self, request_id: StartAgentRequestId) -> bool {
|
||||||
|
self.pending.contains_key(&request_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_dispatches_for_action(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
action_id: &AIAgentActionId,
|
||||||
|
) -> usize {
|
||||||
|
let request_ids = self
|
||||||
|
.pending
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(request_id, pending)| {
|
||||||
|
(pending.parent_conversation_id == conversation_id
|
||||||
|
&& &pending.action_id == action_id)
|
||||||
|
.then_some(*request_id)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let detached_count = request_ids.len();
|
||||||
|
for request_id in request_ids {
|
||||||
|
self.detach_dispatch(request_id);
|
||||||
|
}
|
||||||
|
detached_count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels only the caller's pending tool wait. A child that was already created keeps
|
||||||
|
/// running independently and remains available in conversation history.
|
||||||
|
pub(super) fn cancel_execution(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
action_id: &AIAgentActionId,
|
||||||
|
) {
|
||||||
|
self.cancel_dispatches_for_action(conversation_id, action_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn preprocess_action(
|
pub(super) fn preprocess_action(
|
||||||
@@ -666,7 +860,7 @@ fn should_cleanup_failed_child_launch(status: &ConversationStatus) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_agent_error_message_for_status(
|
fn start_agent_startup_error_message_for_status(
|
||||||
status: &ConversationStatus,
|
status: &ConversationStatus,
|
||||||
error_message: Option<&str>,
|
error_message: Option<&str>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
@@ -701,6 +895,26 @@ fn start_agent_error_message_for_status(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn direct_child_error_message_for_status(
|
||||||
|
status: &ConversationStatus,
|
||||||
|
error_message: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
match status {
|
||||||
|
ConversationStatus::Error => Some(
|
||||||
|
error_message
|
||||||
|
.filter(|message| !message.trim().is_empty())
|
||||||
|
.unwrap_or("Child agent failed")
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
ConversationStatus::Cancelled => Some("Child agent was cancelled by the user.".to_string()),
|
||||||
|
ConversationStatus::InProgress
|
||||||
|
| ConversationStatus::TransientError
|
||||||
|
| ConversationStatus::Success
|
||||||
|
| ConversationStatus::Blocked { .. }
|
||||||
|
| ConversationStatus::WaitingForEvents => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Entity for StartAgentExecutor {
|
impl Entity for StartAgentExecutor {
|
||||||
type Event = StartAgentExecutorEvent;
|
type Event = StartAgentExecutorEvent;
|
||||||
}
|
}
|
||||||
@@ -715,6 +929,14 @@ pub enum StartAgentExecutorEvent {
|
|||||||
parent_conversation_id: AIConversationId,
|
parent_conversation_id: AIConversationId,
|
||||||
child_conversation_id: AIConversationId,
|
child_conversation_id: AIConversationId,
|
||||||
},
|
},
|
||||||
|
/// A RunAgents child conversation is available for live status and
|
||||||
|
/// navigation in the owning action card.
|
||||||
|
RunAgentsChildConversationCreated {
|
||||||
|
action_id: AIAgentActionId,
|
||||||
|
agent_name: String,
|
||||||
|
parent_conversation_id: AIConversationId,
|
||||||
|
child_conversation_id: AIConversationId,
|
||||||
|
},
|
||||||
/// A child agent failed at the launch stage (never started a server-side
|
/// A child agent failed at the launch stage (never started a server-side
|
||||||
/// run). The owning terminal view removes its hidden pane and conversation
|
/// run). The owning terminal view removes its hidden pane and conversation
|
||||||
/// so the orchestration pill bar does not retain a dead chip.
|
/// so the orchestration pill bar does not retain a dead chip.
|
||||||
|
|||||||
@@ -28,6 +28,37 @@ impl Entity for CapturedDirectProviderChildLinks {
|
|||||||
type Event = ();
|
type Event = ();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct CapturedStartAgentPrompts(Vec<String>);
|
||||||
|
|
||||||
|
impl Entity for CapturedStartAgentPrompts {
|
||||||
|
type Event = ();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct CapturedRunAgentsChildLinks(
|
||||||
|
Vec<(AIAgentActionId, String, AIConversationId, AIConversationId)>,
|
||||||
|
);
|
||||||
|
|
||||||
|
impl Entity for CapturedRunAgentsChildLinks {
|
||||||
|
type Event = ();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_start_agent_prompts(
|
||||||
|
app: &mut App,
|
||||||
|
executor: &ModelHandle<StartAgentExecutor>,
|
||||||
|
) -> ModelHandle<CapturedStartAgentPrompts> {
|
||||||
|
let captured = app.add_model(|_| CapturedStartAgentPrompts::default());
|
||||||
|
captured.update(app, |_, ctx| {
|
||||||
|
ctx.subscribe_to_model(executor, |captured, _, event, _ctx| {
|
||||||
|
if let StartAgentExecutorEvent::CreateAgent(request) = event {
|
||||||
|
captured.0.push(request.prompt.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
captured
|
||||||
|
}
|
||||||
|
|
||||||
fn capture_direct_provider_child_links(
|
fn capture_direct_provider_child_links(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
executor: &ModelHandle<StartAgentExecutor>,
|
executor: &ModelHandle<StartAgentExecutor>,
|
||||||
@@ -52,6 +83,32 @@ fn capture_direct_provider_child_links(
|
|||||||
captured
|
captured
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn capture_run_agents_child_links(
|
||||||
|
app: &mut App,
|
||||||
|
executor: &ModelHandle<StartAgentExecutor>,
|
||||||
|
) -> ModelHandle<CapturedRunAgentsChildLinks> {
|
||||||
|
let captured = app.add_model(|_| CapturedRunAgentsChildLinks::default());
|
||||||
|
captured.update(app, |_, ctx| {
|
||||||
|
ctx.subscribe_to_model(executor, |captured, _, event, _ctx| {
|
||||||
|
if let StartAgentExecutorEvent::RunAgentsChildConversationCreated {
|
||||||
|
action_id,
|
||||||
|
agent_name,
|
||||||
|
parent_conversation_id,
|
||||||
|
child_conversation_id,
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
captured.0.push((
|
||||||
|
action_id.clone(),
|
||||||
|
agent_name.clone(),
|
||||||
|
*parent_conversation_id,
|
||||||
|
*child_conversation_id,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
captured
|
||||||
|
}
|
||||||
|
|
||||||
fn build_start_agent_action(
|
fn build_start_agent_action(
|
||||||
version: StartAgentVersion,
|
version: StartAgentVersion,
|
||||||
execution_mode: StartAgentExecutionMode,
|
execution_mode: StartAgentExecutionMode,
|
||||||
@@ -79,6 +136,192 @@ fn build_start_agent_action_with_prompt(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_wraps_child_prompt_with_leaf_worker_contract() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||||
|
let executor = app.add_model(StartAgentExecutor::new);
|
||||||
|
let captured = capture_start_agent_prompts(&mut app, &executor);
|
||||||
|
let root_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||||
|
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||||
|
});
|
||||||
|
let action = build_start_agent_action(
|
||||||
|
StartAgentVersion::V1,
|
||||||
|
StartAgentExecutionMode::local_with_defaults(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let execution = executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor
|
||||||
|
.execute(
|
||||||
|
ExecuteActionInput {
|
||||||
|
action: &action,
|
||||||
|
conversation_id: root_conversation_id,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
assert!(matches!(execution, AnyActionExecution::Async { .. }));
|
||||||
|
captured.read(&app, |captured, _ctx| {
|
||||||
|
assert_eq!(captured.0.len(), 1);
|
||||||
|
assert!(captured.0[0].contains("You are a leaf worker"));
|
||||||
|
assert!(
|
||||||
|
captured.0[0].contains("Do not launch, delegate to, or create additional agents")
|
||||||
|
);
|
||||||
|
assert!(captured.0[0].ends_with("Assigned task:\nInvestigate the failure"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_denies_start_agent_from_child_conversation() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||||
|
let executor = app.add_model(StartAgentExecutor::new);
|
||||||
|
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||||
|
let conversation_id =
|
||||||
|
history.start_new_conversation(terminal_view_id, false, false, false, ctx);
|
||||||
|
history
|
||||||
|
.conversation_mut(&conversation_id)
|
||||||
|
.expect("conversation should exist")
|
||||||
|
.set_parent_agent_id("parent-agent".to_string());
|
||||||
|
conversation_id
|
||||||
|
});
|
||||||
|
let action = build_start_agent_action(
|
||||||
|
StartAgentVersion::V1,
|
||||||
|
StartAgentExecutionMode::local_with_defaults(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let execution = executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor
|
||||||
|
.execute(
|
||||||
|
ExecuteActionInput {
|
||||||
|
action: &action,
|
||||||
|
conversation_id: child_conversation_id,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
execution,
|
||||||
|
AnyActionExecution::Sync(AIAgentActionResultType::StartAgent(
|
||||||
|
StartAgentResult::Error { error, .. }
|
||||||
|
)) if error.contains("leaf workers")
|
||||||
|
));
|
||||||
|
executor.read(&app, |executor, _ctx| {
|
||||||
|
assert!(executor.pending.is_empty());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_denies_child_conversation_defense_in_depth() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||||
|
let executor = app.add_model(StartAgentExecutor::new);
|
||||||
|
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||||
|
let conversation_id =
|
||||||
|
history.start_new_conversation(terminal_view_id, false, false, false, ctx);
|
||||||
|
history
|
||||||
|
.conversation_mut(&conversation_id)
|
||||||
|
.expect("conversation should exist")
|
||||||
|
.set_parent_agent_id("parent-agent".to_string());
|
||||||
|
conversation_id
|
||||||
|
});
|
||||||
|
|
||||||
|
let dispatch = executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor.dispatch(
|
||||||
|
AIAgentActionId::from("run-agents-action".to_string()),
|
||||||
|
"grandchild".to_string(),
|
||||||
|
"Do more work".to_string(),
|
||||||
|
StartAgentExecutionMode::local_with_defaults(),
|
||||||
|
None,
|
||||||
|
child_conversation_id,
|
||||||
|
None,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
dispatch.receiver.try_recv(),
|
||||||
|
Ok(StartAgentOutcome::Error(error)) if error.contains("leaf workers")
|
||||||
|
));
|
||||||
|
executor.read(&app, |executor, _ctx| {
|
||||||
|
assert!(executor.pending.is_empty());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_execution_waits_for_completion() {
|
||||||
|
assert_eq!(
|
||||||
|
wait_policy_for_execution_mode(&StartAgentExecutionMode::local_with_defaults()),
|
||||||
|
StartAgentWaitPolicy::Completion
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detach_dispatch_rejects_late_child_callback() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
initialize_history_persistence_for_tests(&mut app);
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||||
|
let executor = app.add_model(StartAgentExecutor::new);
|
||||||
|
let parent_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||||
|
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||||
|
});
|
||||||
|
let dispatch = executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor.dispatch(
|
||||||
|
AIAgentActionId::from("run-agents".to_string()),
|
||||||
|
"child".to_string(),
|
||||||
|
"work".to_string(),
|
||||||
|
StartAgentExecutionMode::Remote {
|
||||||
|
environment_id: "environment".to_string(),
|
||||||
|
skill_references: Vec::new(),
|
||||||
|
model_id: "model".to_string(),
|
||||||
|
computer_use_enabled: false,
|
||||||
|
worker_host: String::new(),
|
||||||
|
harness_type: "oz".to_string(),
|
||||||
|
title: String::new(),
|
||||||
|
auth_secret_name: None,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
parent_conversation_id,
|
||||||
|
Some(PARENT_RUN_ID.to_string()),
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(executor.update(&mut app, |executor, _| {
|
||||||
|
executor.detach_dispatch(dispatch.request_id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||||
|
history.start_new_child_conversation(
|
||||||
|
terminal_view_id,
|
||||||
|
"child".to_string(),
|
||||||
|
parent_conversation_id,
|
||||||
|
None,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
history_model.update(&mut app, |history, ctx| {
|
||||||
|
history.record_new_conversation_request_complete(
|
||||||
|
dispatch.request_id,
|
||||||
|
child_conversation_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
executor.read(&app, |executor, _| assert!(executor.pending.is_empty()));
|
||||||
|
assert!(dispatch.receiver.try_recv().is_err());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
|
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
|
||||||
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command(
|
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command(
|
||||||
@@ -543,6 +786,426 @@ fn hosted_child_link_does_not_publish_direct_provider_panel_event() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PendingDirectProviderChild {
|
||||||
|
action_id: AIAgentActionId,
|
||||||
|
parent_conversation_id: AIConversationId,
|
||||||
|
history_model: ModelHandle<BlocklistAIHistoryModel>,
|
||||||
|
executor: ModelHandle<StartAgentExecutor>,
|
||||||
|
captured_cleanup: ModelHandle<CapturedCleanupEvents>,
|
||||||
|
direct_links: ModelHandle<CapturedDirectProviderChildLinks>,
|
||||||
|
run_agents_links: ModelHandle<CapturedRunAgentsChildLinks>,
|
||||||
|
terminal_view_id: EntityId,
|
||||||
|
child_conversation_id: AIConversationId,
|
||||||
|
dispatch: StartAgentDispatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_pending_direct_provider_child(app: &mut App) -> PendingDirectProviderChild {
|
||||||
|
initialize_history_persistence_for_tests(app);
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||||
|
let executor = app.add_model(StartAgentExecutor::new);
|
||||||
|
let captured_cleanup = app.add_model(|_| CapturedCleanupEvents::default());
|
||||||
|
captured_cleanup.update(app, |_, ctx| {
|
||||||
|
ctx.subscribe_to_model(&executor, |captured, _, event, _ctx| {
|
||||||
|
if let StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } = event {
|
||||||
|
captured.0.push(*conversation_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let direct_links = capture_direct_provider_child_links(app, &executor);
|
||||||
|
let run_agents_links = capture_run_agents_child_links(app, &executor);
|
||||||
|
let parent_conversation_id = history_model.update(app, |history_model, ctx| {
|
||||||
|
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||||
|
});
|
||||||
|
let action_id = AIAgentActionId::from("run-agents-action".to_string());
|
||||||
|
let dispatch = executor.update(app, |executor, ctx| {
|
||||||
|
executor.dispatch(
|
||||||
|
action_id.clone(),
|
||||||
|
"child".to_string(),
|
||||||
|
"Investigate the failure".to_string(),
|
||||||
|
StartAgentExecutionMode::local_with_defaults(),
|
||||||
|
None,
|
||||||
|
parent_conversation_id,
|
||||||
|
None,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let child_conversation_id = history_model.update(app, |history_model, ctx| {
|
||||||
|
history_model.start_new_child_conversation(
|
||||||
|
terminal_view_id,
|
||||||
|
"child".to_string(),
|
||||||
|
parent_conversation_id,
|
||||||
|
None,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
history_model.update(app, |history_model, ctx| {
|
||||||
|
history_model.record_new_conversation_request_complete(
|
||||||
|
FIRST_REQUEST_ID,
|
||||||
|
child_conversation_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
PendingDirectProviderChild {
|
||||||
|
action_id,
|
||||||
|
parent_conversation_id,
|
||||||
|
history_model,
|
||||||
|
executor,
|
||||||
|
captured_cleanup,
|
||||||
|
direct_links,
|
||||||
|
run_agents_links,
|
||||||
|
terminal_view_id,
|
||||||
|
child_conversation_id,
|
||||||
|
dispatch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_provider_nonterminal_states_remain_pending_until_cancelled() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let state = dispatch_pending_direct_provider_child(&mut app);
|
||||||
|
assert_eq!(state.dispatch.wait_policy, StartAgentWaitPolicy::Completion);
|
||||||
|
|
||||||
|
for status in [
|
||||||
|
ConversationStatus::Blocked {
|
||||||
|
blocked_action: "Waiting for user input".to_string(),
|
||||||
|
},
|
||||||
|
ConversationStatus::TransientError,
|
||||||
|
ConversationStatus::WaitingForEvents,
|
||||||
|
] {
|
||||||
|
state.history_model.update(&mut app, |history_model, ctx| {
|
||||||
|
history_model.update_conversation_status(
|
||||||
|
state.terminal_view_id,
|
||||||
|
state.child_conversation_id,
|
||||||
|
status,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
state.dispatch.receiver.try_recv(),
|
||||||
|
Err(async_channel::TryRecvError::Empty)
|
||||||
|
));
|
||||||
|
state.executor.read(&app, |executor, _| {
|
||||||
|
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state.history_model.update(&mut app, |history_model, ctx| {
|
||||||
|
history_model.update_conversation_status(
|
||||||
|
state.terminal_view_id,
|
||||||
|
state.child_conversation_id,
|
||||||
|
ConversationStatus::Cancelled,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
state.dispatch.receiver.try_recv(),
|
||||||
|
Ok(StartAgentOutcome::Error(error))
|
||||||
|
if error == "Child agent was cancelled by the user."
|
||||||
|
));
|
||||||
|
state.executor.read(&app, |executor, _| {
|
||||||
|
assert!(executor.pending.is_empty());
|
||||||
|
});
|
||||||
|
state.captured_cleanup.read(&app, |captured, _| {
|
||||||
|
assert!(captured.0.is_empty());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_provider_error_preserves_child_for_inspection() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let state = dispatch_pending_direct_provider_child(&mut app);
|
||||||
|
state.history_model.update(&mut app, |history_model, ctx| {
|
||||||
|
history_model.update_conversation_status_with_error(
|
||||||
|
state.terminal_view_id,
|
||||||
|
state.child_conversation_id,
|
||||||
|
ConversationStatus::Error,
|
||||||
|
Some(RenderableAIError::other("Child execution failed", false)),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
state.dispatch.receiver.try_recv(),
|
||||||
|
Ok(StartAgentOutcome::Error(error)) if error == "Child execution failed"
|
||||||
|
));
|
||||||
|
state.captured_cleanup.read(&app, |captured, _| {
|
||||||
|
assert!(captured.0.is_empty());
|
||||||
|
});
|
||||||
|
state.history_model.read(&app, |history_model, _| {
|
||||||
|
assert!(history_model
|
||||||
|
.conversation(&state.child_conversation_id)
|
||||||
|
.is_some());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelling_standalone_start_agent_drops_dispatch_but_keeps_child_running() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
initialize_history_persistence_for_tests(&mut app);
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||||
|
let executor = app.add_model(StartAgentExecutor::new);
|
||||||
|
let parent_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||||
|
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||||
|
});
|
||||||
|
let action = build_start_agent_action(
|
||||||
|
StartAgentVersion::V1,
|
||||||
|
StartAgentExecutionMode::local_with_defaults(),
|
||||||
|
);
|
||||||
|
let execution = executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor
|
||||||
|
.execute(
|
||||||
|
ExecuteActionInput {
|
||||||
|
action: &action,
|
||||||
|
conversation_id: parent_conversation_id,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||||
|
history.start_new_child_conversation(
|
||||||
|
terminal_view_id,
|
||||||
|
"child".to_string(),
|
||||||
|
parent_conversation_id,
|
||||||
|
None,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
history_model.update(&mut app, |history, ctx| {
|
||||||
|
history.record_new_conversation_request_complete(
|
||||||
|
FIRST_REQUEST_ID,
|
||||||
|
child_conversation_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
executor.update(&mut app, |executor, _| {
|
||||||
|
executor.cancel_execution(parent_conversation_id, &action.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
executor.read(&app, |executor, _| assert!(executor.pending.is_empty()));
|
||||||
|
history_model.read(&app, |history, _| {
|
||||||
|
assert_eq!(
|
||||||
|
history
|
||||||
|
.conversation(&child_conversation_id)
|
||||||
|
.expect("child should remain in history")
|
||||||
|
.status(),
|
||||||
|
&ConversationStatus::InProgress
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let AnyActionExecution::Async { execute_future, .. } = execution else {
|
||||||
|
panic!("expected async StartAgent execution");
|
||||||
|
};
|
||||||
|
let _ = execute_future.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelling_duplicate_action_id_detaches_only_the_matching_conversation() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
initialize_history_persistence_for_tests(&mut app);
|
||||||
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||||
|
let executor = app.add_model(StartAgentExecutor::new);
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let first_conversation = history_model.update(&mut app, |history, ctx| {
|
||||||
|
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||||
|
});
|
||||||
|
let second_conversation = history_model.update(&mut app, |history, ctx| {
|
||||||
|
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||||
|
});
|
||||||
|
let action = build_start_agent_action(
|
||||||
|
StartAgentVersion::V1,
|
||||||
|
StartAgentExecutionMode::local_with_defaults(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let first = executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor
|
||||||
|
.execute(
|
||||||
|
ExecuteActionInput {
|
||||||
|
action: &action,
|
||||||
|
conversation_id: first_conversation,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
let second = executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor
|
||||||
|
.execute(
|
||||||
|
ExecuteActionInput {
|
||||||
|
action: &action,
|
||||||
|
conversation_id: second_conversation,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
assert!(matches!(first, AnyActionExecution::Async { .. }));
|
||||||
|
assert!(matches!(second, AnyActionExecution::Async { .. }));
|
||||||
|
|
||||||
|
executor.update(&mut app, |executor, _| {
|
||||||
|
executor.cancel_execution(first_conversation, &action.id);
|
||||||
|
assert_eq!(executor.pending.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
executor
|
||||||
|
.pending
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.parent_conversation_id,
|
||||||
|
second_conversation
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removing_direct_provider_child_resolves_pending_wait() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let state = dispatch_pending_direct_provider_child(&mut app);
|
||||||
|
state.history_model.update(&mut app, |history_model, ctx| {
|
||||||
|
history_model.remove_conversation(
|
||||||
|
state.child_conversation_id,
|
||||||
|
state.terminal_view_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
state.dispatch.receiver.try_recv(),
|
||||||
|
Ok(StartAgentOutcome::Error(error))
|
||||||
|
if error == "Child agent conversation was removed by the user."
|
||||||
|
));
|
||||||
|
state.executor.read(&app, |executor, _| {
|
||||||
|
assert!(executor.pending.is_empty());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_direct_provider_child_resolves_pending_wait() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let state = dispatch_pending_direct_provider_child(&mut app);
|
||||||
|
state.history_model.update(&mut app, |history_model, ctx| {
|
||||||
|
history_model.delete_conversation(
|
||||||
|
state.child_conversation_id,
|
||||||
|
Some(state.terminal_view_id),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
state.dispatch.receiver.try_recv(),
|
||||||
|
Ok(StartAgentOutcome::Error(error))
|
||||||
|
if error == "Child agent conversation was removed by the user."
|
||||||
|
));
|
||||||
|
state.executor.read(&app, |executor, _| {
|
||||||
|
assert!(executor.pending.is_empty());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_agents_dispatch_publishes_only_run_agents_child_link() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let state = dispatch_pending_direct_provider_child(&mut app);
|
||||||
|
|
||||||
|
state.direct_links.read(&app, |captured, _| {
|
||||||
|
assert!(captured.0.is_empty());
|
||||||
|
});
|
||||||
|
state.run_agents_links.read(&app, |captured, _| {
|
||||||
|
assert_eq!(
|
||||||
|
captured.0,
|
||||||
|
vec![(
|
||||||
|
state.action_id.clone(),
|
||||||
|
"child".to_string(),
|
||||||
|
state.parent_conversation_id,
|
||||||
|
state.child_conversation_id,
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reattach_reuses_persisted_child_without_launching_another_agent() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
initialize_history_persistence_for_tests(&mut app);
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||||
|
let executor = app.add_model(StartAgentExecutor::new);
|
||||||
|
let captured_prompts = capture_start_agent_prompts(&mut app, &executor);
|
||||||
|
let captured_links = capture_run_agents_child_links(&mut app, &executor);
|
||||||
|
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||||
|
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||||
|
});
|
||||||
|
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||||
|
history_model.start_new_child_conversation(
|
||||||
|
terminal_view_id,
|
||||||
|
"child".to_string(),
|
||||||
|
parent_conversation_id,
|
||||||
|
None,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let action_id = AIAgentActionId::from("run-agents-action".to_string());
|
||||||
|
|
||||||
|
let dispatch = executor.update(&mut app, |executor, ctx| {
|
||||||
|
executor.reattach(
|
||||||
|
action_id.clone(),
|
||||||
|
"child".to_string(),
|
||||||
|
parent_conversation_id,
|
||||||
|
child_conversation_id,
|
||||||
|
StartAgentWaitPolicy::Completion,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(dispatch.wait_policy, StartAgentWaitPolicy::Completion);
|
||||||
|
assert!(matches!(
|
||||||
|
dispatch.receiver.try_recv(),
|
||||||
|
Err(async_channel::TryRecvError::Empty)
|
||||||
|
));
|
||||||
|
captured_prompts.read(&app, |captured, _| {
|
||||||
|
assert!(captured.0.is_empty());
|
||||||
|
});
|
||||||
|
captured_links.read(&app, |captured, _| {
|
||||||
|
assert_eq!(
|
||||||
|
captured.0,
|
||||||
|
vec![(
|
||||||
|
action_id,
|
||||||
|
"child".to_string(),
|
||||||
|
parent_conversation_id,
|
||||||
|
child_conversation_id,
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
history_model.update(&mut app, |history_model, ctx| {
|
||||||
|
history_model.update_conversation_status(
|
||||||
|
terminal_view_id,
|
||||||
|
child_conversation_id,
|
||||||
|
ConversationStatus::Success,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
dispatch.receiver.try_recv(),
|
||||||
|
Ok(StartAgentOutcome::Completed { agent_id, .. })
|
||||||
|
if agent_id == child_conversation_id.to_string()
|
||||||
|
));
|
||||||
|
executor.read(&app, |executor, _| {
|
||||||
|
assert!(executor.pending.is_empty());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn execute_waits_for_direct_provider_child_and_returns_its_output() {
|
fn execute_waits_for_direct_provider_child_and_returns_its_output() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
@@ -596,7 +1259,7 @@ fn execute_waits_for_direct_provider_child_and_returns_its_output() {
|
|||||||
.pending
|
.pending
|
||||||
.get(&FIRST_REQUEST_ID)
|
.get(&FIRST_REQUEST_ID)
|
||||||
.expect("direct child should remain pending until completion");
|
.expect("direct child should remain pending until completion");
|
||||||
assert!(pending.wait_for_completion);
|
assert_eq!(pending.wait_policy, StartAgentWaitPolicy::Completion);
|
||||||
});
|
});
|
||||||
|
|
||||||
history_model.update(&mut app, |history_model, ctx| {
|
history_model.update(&mut app, |history_model, ctx| {
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ fn initialize_upload_artifact_test(
|
|||||||
app.add_singleton_model(TeamTesterStatus::mock);
|
app.add_singleton_model(TeamTesterStatus::mock);
|
||||||
app.add_singleton_model(UpdateManager::mock);
|
app.add_singleton_model(UpdateManager::mock);
|
||||||
app.add_singleton_model(CloudModel::mock);
|
app.add_singleton_model(CloudModel::mock);
|
||||||
|
app.add_singleton_model(|ctx| {
|
||||||
|
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
|
||||||
|
});
|
||||||
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
|
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
|
||||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||||
let profiles = app.add_singleton_model(|ctx| {
|
let profiles = app.add_singleton_model(|ctx| {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::ai::agent::task::TaskId;
|
use crate::ai::agent::task::TaskId;
|
||||||
use crate::ai::agent::AIAgentActionResultType;
|
use crate::ai::agent::{
|
||||||
|
AIAgentAction, AIAgentActionResultType, AIAgentActionType, AnyFileContent, FileContext,
|
||||||
|
GrepResult, ReadFilesResult,
|
||||||
|
};
|
||||||
|
|
||||||
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
|
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
|
||||||
Arc::new(AIAgentActionResult {
|
Arc::new(AIAgentActionResult {
|
||||||
@@ -13,6 +16,44 @@ fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn action_result(id: &str, result: AIAgentActionResultType) -> AIAgentActionResult {
|
||||||
|
AIAgentActionResult {
|
||||||
|
id: AIAgentActionId::from(id.to_owned()),
|
||||||
|
task_id: TaskId::new("task".to_owned()),
|
||||||
|
result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action(id: &str) -> AIAgentAction {
|
||||||
|
AIAgentAction {
|
||||||
|
id: AIAgentActionId::from(id.to_string()),
|
||||||
|
action: AIAgentActionType::InitProject,
|
||||||
|
task_id: TaskId::new("task".to_string()),
|
||||||
|
requires_result: true,
|
||||||
|
tool_name: Some("init_project".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_tool_batch(call_ids: &[&str]) -> PendingToolBatch {
|
||||||
|
PendingToolBatch {
|
||||||
|
work_id: galaxy_agent_core::ExternalWorkId {
|
||||||
|
run_id: galaxy_agent_core::ProviderRunId::new("run"),
|
||||||
|
epoch: galaxy_agent_core::RunEpoch::new(7),
|
||||||
|
},
|
||||||
|
calls: call_ids
|
||||||
|
.iter()
|
||||||
|
.map(|call_id| galaxy_agent_core::PendingToolCall {
|
||||||
|
call: galaxy_agent_core::ToolCall {
|
||||||
|
id: (*call_id).to_string(),
|
||||||
|
name: "init_project".to_string(),
|
||||||
|
arguments: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
state: galaxy_agent_core::PendingToolCallState::Proposed,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize {
|
fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize {
|
||||||
let mut current_phase = None;
|
let mut current_phase = None;
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
@@ -35,6 +76,34 @@ fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> us
|
|||||||
count
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_action_correlations_require_the_exact_unresolved_batch_order() {
|
||||||
|
let conversation_id = AIConversationId::new();
|
||||||
|
let batch = pending_tool_batch(&["first", "second"]);
|
||||||
|
let actions = vec![action("first"), action("second")];
|
||||||
|
|
||||||
|
let correlations = provider_action_correlations(&actions, conversation_id, &batch).unwrap();
|
||||||
|
assert_eq!(correlations.len(), 2);
|
||||||
|
assert_eq!(correlations[0].0, (conversation_id, actions[0].id.clone()));
|
||||||
|
assert_eq!(correlations[0].1.run_id, batch.work_id.run_id);
|
||||||
|
assert_eq!(correlations[0].1.epoch, batch.work_id.epoch);
|
||||||
|
assert_eq!(correlations[0].1.call_id, "first");
|
||||||
|
|
||||||
|
let error = provider_action_correlations(
|
||||||
|
&[action("second"), action("first")],
|
||||||
|
conversation_id,
|
||||||
|
&batch,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
ProviderActionQueueError::ActionSetMismatch {
|
||||||
|
expected: vec!["first".to_string(), "second".to_string()],
|
||||||
|
received: vec!["second".to_string(), "first".to_string()],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parallel_phase_only_admits_matching_autoexecutable_actions() {
|
fn parallel_phase_only_admits_matching_autoexecutable_actions() {
|
||||||
let phase =
|
let phase =
|
||||||
@@ -71,6 +140,47 @@ fn phased_scheduling_stops_at_serial_barrier_and_resumes_afterward() {
|
|||||||
assert_eq!(count_startable_actions_for_pass(&actions[3..]), 2);
|
assert_eq!(count_startable_actions_for_pass(&actions[3..]), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn automatic_retries_only_target_actions_deferred_as_not_ready() {
|
||||||
|
let conversation_id = AIConversationId::new();
|
||||||
|
let action_id = AIAgentActionId::from("file-edit".to_string());
|
||||||
|
let mut tracker = NotReadyActionTracker::default();
|
||||||
|
|
||||||
|
tracker.update_after_attempt(
|
||||||
|
conversation_id,
|
||||||
|
action_id.clone(),
|
||||||
|
NotExecutedReason::NotReady,
|
||||||
|
ActionExecutionInitiator::Automatic,
|
||||||
|
);
|
||||||
|
assert!(tracker.should_retry(conversation_id, &action_id));
|
||||||
|
assert!(!ActionExecutionInitiator::Automatic.is_user_initiated());
|
||||||
|
|
||||||
|
tracker.update_after_attempt(
|
||||||
|
conversation_id,
|
||||||
|
action_id.clone(),
|
||||||
|
NotExecutedReason::NotReady,
|
||||||
|
ActionExecutionInitiator::User,
|
||||||
|
);
|
||||||
|
assert!(!tracker.should_retry(conversation_id, &action_id));
|
||||||
|
|
||||||
|
tracker.update_after_attempt(
|
||||||
|
conversation_id,
|
||||||
|
action_id.clone(),
|
||||||
|
NotExecutedReason::NeedsConfirmation,
|
||||||
|
ActionExecutionInitiator::Automatic,
|
||||||
|
);
|
||||||
|
assert!(!tracker.should_retry(conversation_id, &action_id));
|
||||||
|
|
||||||
|
tracker.update_after_attempt(
|
||||||
|
conversation_id,
|
||||||
|
action_id.clone(),
|
||||||
|
NotExecutedReason::WaitingOnSharer,
|
||||||
|
ActionExecutionInitiator::Automatic,
|
||||||
|
);
|
||||||
|
assert!(!tracker.should_retry(conversation_id, &action_id));
|
||||||
|
assert!(ActionExecutionInitiator::User.is_user_initiated());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finished_results_stay_in_original_action_order() {
|
fn finished_results_stay_in_original_action_order() {
|
||||||
let action_order = HashMap::from([
|
let action_order = HashMap::from([
|
||||||
@@ -84,8 +194,7 @@ fn finished_results_stay_in_original_action_order() {
|
|||||||
make_action_result("second"),
|
make_action_result("second"),
|
||||||
];
|
];
|
||||||
|
|
||||||
finished_results
|
sort_action_results_by_order(&mut finished_results, &action_order);
|
||||||
.sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX));
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
finished_results[0].id,
|
finished_results[0].id,
|
||||||
@@ -100,3 +209,265 @@ fn finished_results_stay_in_original_action_order() {
|
|||||||
AIAgentActionId::from("third".to_owned())
|
AIAgentActionId::from("third".to_owned())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_tool_results_preserve_success_failure_cancellation_and_denial() {
|
||||||
|
let success = domain_tool_result(
|
||||||
|
&action_result("success", AIAgentActionResultType::InitProject),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let failure = domain_tool_result(
|
||||||
|
&action_result(
|
||||||
|
"failure",
|
||||||
|
AIAgentActionResultType::Grep(GrepResult::Error("boom".to_string())),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let cancelled_result = action_result(
|
||||||
|
"cancelled",
|
||||||
|
AIAgentActionResultType::Grep(GrepResult::Cancelled),
|
||||||
|
);
|
||||||
|
let cancelled = domain_tool_result(&cancelled_result, false);
|
||||||
|
let denied = domain_tool_result(&cancelled_result, true);
|
||||||
|
|
||||||
|
assert_eq!(success.status, ToolResultStatus::Success);
|
||||||
|
assert_eq!(failure.status, ToolResultStatus::Error);
|
||||||
|
assert_eq!(cancelled.status, ToolResultStatus::Cancelled);
|
||||||
|
assert_eq!(denied.status, ToolResultStatus::Denied);
|
||||||
|
assert_eq!(success.call_id, "success");
|
||||||
|
assert_eq!(failure.call_id, "failure");
|
||||||
|
assert_eq!(cancelled.call_id, "cancelled");
|
||||||
|
assert_eq!(denied.call_id, "cancelled");
|
||||||
|
assert!(!cancelled.content.contains("Permission denied"));
|
||||||
|
assert!(denied.content.contains("Permission denied by the user"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_read_result_contains_the_file_contents_for_the_next_model_turn() {
|
||||||
|
let result = action_result(
|
||||||
|
"read-call",
|
||||||
|
AIAgentActionResultType::ReadFiles(ReadFilesResult::Success {
|
||||||
|
files: vec![FileContext::new(
|
||||||
|
"/workspace/src/lib.rs".to_string(),
|
||||||
|
AnyFileContent::StringContent("pub fn answer() -> u8 { 42 }".to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = domain_tool_result(&result, false);
|
||||||
|
|
||||||
|
assert_eq!(result.status, ToolResultStatus::Success);
|
||||||
|
assert_eq!(result.call_id, "read-call");
|
||||||
|
assert!(result.content.contains("/workspace/src/lib.rs"));
|
||||||
|
assert!(result.content.contains("pub fn answer() -> u8 { 42 }"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn action_permission_kinds_match_the_safety_boundary() {
|
||||||
|
assert_eq!(
|
||||||
|
permission_kind_for_action(&AIAgentActionType::Grep {
|
||||||
|
queries: vec!["needle".to_string()],
|
||||||
|
path: ".".to_string(),
|
||||||
|
}),
|
||||||
|
PermissionKind::Read
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
permission_kind_for_action(&AIAgentActionType::InitProject),
|
||||||
|
PermissionKind::Write
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
permission_kind_for_action(&AIAgentActionType::RequestCommandOutput {
|
||||||
|
command: "cargo test".to_string(),
|
||||||
|
is_read_only: Some(true),
|
||||||
|
is_risky: Some(false),
|
||||||
|
wait_until_completion: true,
|
||||||
|
uses_pager: Some(false),
|
||||||
|
rationale: None,
|
||||||
|
citations: Vec::new(),
|
||||||
|
}),
|
||||||
|
PermissionKind::Execute
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
permission_kind_for_action(&AIAgentActionType::CallMCPTool {
|
||||||
|
server_id: None,
|
||||||
|
name: "tool".to_string(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
}),
|
||||||
|
PermissionKind::ExternalTool
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn denied_permission_event_resolves_the_pending_call() {
|
||||||
|
let action = action("call-1");
|
||||||
|
|
||||||
|
let ToolEvent::PermissionResolved {
|
||||||
|
request_id,
|
||||||
|
call_id,
|
||||||
|
decision,
|
||||||
|
} = permission_denied_tool_event(&action)
|
||||||
|
else {
|
||||||
|
panic!("expected a permission resolution event");
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(request_id, "permission:call-1");
|
||||||
|
assert_eq!(call_id, "call-1");
|
||||||
|
assert_eq!(
|
||||||
|
decision,
|
||||||
|
PermissionDecision::Denied {
|
||||||
|
reason: Some("Permission denied by the user.".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_owned_denial_suppresses_duplicate_completion() {
|
||||||
|
assert!(!should_emit_tool_completion(true, true));
|
||||||
|
assert!(should_emit_tool_completion(true, false));
|
||||||
|
assert!(should_emit_tool_completion(false, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_rejecting_a_blocked_action_is_a_permission_denial() {
|
||||||
|
assert!(is_permission_denial(
|
||||||
|
CancellationReason::ManuallyCancelled,
|
||||||
|
Some(&AIActionStatus::Blocked),
|
||||||
|
));
|
||||||
|
assert!(!is_permission_denial(
|
||||||
|
CancellationReason::ManuallyCancelled,
|
||||||
|
Some(&AIActionStatus::Queued),
|
||||||
|
));
|
||||||
|
assert!(!is_permission_denial(
|
||||||
|
CancellationReason::FollowUpSubmitted {
|
||||||
|
is_for_same_conversation: true,
|
||||||
|
},
|
||||||
|
Some(&AIActionStatus::Blocked),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_action_ids_resolve_only_within_the_requested_conversation() {
|
||||||
|
let first_conversation = AIConversationId::new();
|
||||||
|
let second_conversation = AIConversationId::new();
|
||||||
|
let duplicate_id = AIAgentActionId::from("duplicate".to_string());
|
||||||
|
let first_result = make_action_result("duplicate");
|
||||||
|
let mut second_result = action_result("duplicate", AIAgentActionResultType::InitProject);
|
||||||
|
second_result.task_id = TaskId::new("second-task".to_string());
|
||||||
|
let second_result = Arc::new(second_result);
|
||||||
|
let finished_results = HashMap::from([(first_conversation, vec![first_result.clone()])]);
|
||||||
|
let provider_results = HashMap::new();
|
||||||
|
let archive = HashMap::from([
|
||||||
|
(
|
||||||
|
(first_conversation, duplicate_id.clone()),
|
||||||
|
first_result.clone(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(second_conversation, duplicate_id.clone()),
|
||||||
|
second_result.clone(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
action_result_for_conversation(
|
||||||
|
&finished_results,
|
||||||
|
&provider_results,
|
||||||
|
&archive,
|
||||||
|
first_conversation,
|
||||||
|
&duplicate_id,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
&first_result,
|
||||||
|
));
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
action_result_for_conversation(
|
||||||
|
&finished_results,
|
||||||
|
&provider_results,
|
||||||
|
&archive,
|
||||||
|
second_conversation,
|
||||||
|
&duplicate_id,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
&second_result,
|
||||||
|
));
|
||||||
|
assert!(action_result_for_conversation(
|
||||||
|
&finished_results,
|
||||||
|
&provider_results,
|
||||||
|
&archive,
|
||||||
|
AIConversationId::new(),
|
||||||
|
&duplicate_id,
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancellation_permission_inference_uses_the_matching_conversation_status() {
|
||||||
|
let blocked_conversation = AIConversationId::new();
|
||||||
|
let queued_conversation = AIConversationId::new();
|
||||||
|
let duplicate_id = AIAgentActionId::from("duplicate".to_string());
|
||||||
|
let pending_actions = HashMap::from([
|
||||||
|
(blocked_conversation, VecDeque::from([action("duplicate")])),
|
||||||
|
(
|
||||||
|
queued_conversation,
|
||||||
|
VecDeque::from([action("first"), action("duplicate")]),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let running_actions = HashMap::new();
|
||||||
|
|
||||||
|
let blocked_status = pending_action_status(
|
||||||
|
&pending_actions,
|
||||||
|
&running_actions,
|
||||||
|
blocked_conversation,
|
||||||
|
&duplicate_id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let queued_status = pending_action_status(
|
||||||
|
&pending_actions,
|
||||||
|
&running_actions,
|
||||||
|
queued_conversation,
|
||||||
|
&duplicate_id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(is_permission_denial(
|
||||||
|
CancellationReason::ManuallyCancelled,
|
||||||
|
blocked_status.as_ref(),
|
||||||
|
));
|
||||||
|
assert!(!is_permission_denial(
|
||||||
|
CancellationReason::ManuallyCancelled,
|
||||||
|
queued_status.as_ref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn action_lifecycle_events_disambiguate_duplicate_ids_by_conversation() {
|
||||||
|
let first_conversation = AIConversationId::new();
|
||||||
|
let second_conversation = AIConversationId::new();
|
||||||
|
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
|
||||||
|
let events = [
|
||||||
|
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
|
||||||
|
action_id: duplicate_id.clone(),
|
||||||
|
conversation_id: first_conversation,
|
||||||
|
execution_ref: None,
|
||||||
|
},
|
||||||
|
BlocklistAIActionEvent::ExecutingAction {
|
||||||
|
action_id: duplicate_id.clone(),
|
||||||
|
conversation_id: second_conversation,
|
||||||
|
execution_ref: None,
|
||||||
|
},
|
||||||
|
BlocklistAIActionEvent::FinishedAction {
|
||||||
|
action_id: duplicate_id.clone(),
|
||||||
|
conversation_id: first_conversation,
|
||||||
|
cancellation_reason: None,
|
||||||
|
execution_ref: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(events[0].conversation_id(), Some(first_conversation));
|
||||||
|
assert_eq!(events[1].conversation_id(), Some(second_conversation));
|
||||||
|
assert_eq!(events[2].conversation_id(), Some(first_conversation));
|
||||||
|
assert!(events
|
||||||
|
.iter()
|
||||||
|
.all(|event| event.action_id() == &duplicate_id));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1405,6 +1405,7 @@ impl AgentInputFooter {
|
|||||||
) -> Option<Box<dyn Element>> {
|
) -> Option<Box<dyn Element>> {
|
||||||
if !item.available_in().is_available_for_cli()
|
if !item.available_in().is_available_for_cli()
|
||||||
|| !item.available_to_session_viewer(shared_status, false)
|
|| !item.available_to_session_viewer(shared_status, false)
|
||||||
|
|| !item.is_available(app)
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -2016,6 +2017,7 @@ impl AgentInputFooter {
|
|||||||
});
|
});
|
||||||
if !item.available_in().is_available_for_agent_view()
|
if !item.available_in().is_available_for_agent_view()
|
||||||
|| !item.available_to_session_viewer(shared_status, is_cloud_mode)
|
|| !item.available_to_session_viewer(shared_status, is_cloud_mode)
|
||||||
|
|| !item.is_available(app)
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ impl AgentToolbarItemKind {
|
|||||||
pub fn is_available(&self, app: &warpui::AppContext) -> bool {
|
pub fn is_available(&self, app: &warpui::AppContext) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::HandoffToCloud => AISettings::as_ref(app).is_cloud_handoff_enabled(app),
|
Self::HandoffToCloud => AISettings::as_ref(app).is_cloud_handoff_enabled(app),
|
||||||
|
// Retain the enum variant so existing toolbar settings still deserialize.
|
||||||
|
Self::ShareSession => false,
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,11 +217,6 @@ impl AgentToolbarItemKind {
|
|||||||
Self::ContextWindowUsage,
|
Self::ContextWindowUsage,
|
||||||
Self::ModelSelector,
|
Self::ModelSelector,
|
||||||
];
|
];
|
||||||
if FeatureFlag::CreatingSharedSessions.is_enabled()
|
|
||||||
&& FeatureFlag::HOARemoteControl.is_enabled()
|
|
||||||
{
|
|
||||||
items.push(Self::ShareSession);
|
|
||||||
}
|
|
||||||
if FeatureFlag::OzHandoff.is_enabled()
|
if FeatureFlag::OzHandoff.is_enabled()
|
||||||
&& FeatureFlag::HandoffLocalCloud.is_enabled()
|
&& FeatureFlag::HandoffLocalCloud.is_enabled()
|
||||||
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
|
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
|
||||||
@@ -247,11 +244,6 @@ impl AgentToolbarItemKind {
|
|||||||
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
|
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
|
||||||
items.push(Self::FastForwardToggle);
|
items.push(Self::FastForwardToggle);
|
||||||
}
|
}
|
||||||
if FeatureFlag::CreatingSharedSessions.is_enabled()
|
|
||||||
&& FeatureFlag::HOARemoteControl.is_enabled()
|
|
||||||
{
|
|
||||||
items.push(Self::ShareSession);
|
|
||||||
}
|
|
||||||
if FeatureFlag::OzHandoff.is_enabled()
|
if FeatureFlag::OzHandoff.is_enabled()
|
||||||
&& FeatureFlag::HandoffLocalCloud.is_enabled()
|
&& FeatureFlag::HandoffLocalCloud.is_enabled()
|
||||||
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
|
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
|
||||||
@@ -322,3 +314,7 @@ impl From<ContextChipKind> for AgentToolbarItemKind {
|
|||||||
Self::ContextChip(kind)
|
Self::ContextChip(kind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "toolbar_item_tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use super::AgentToolbarItemKind;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_share_session_setting_remains_deserializable() {
|
||||||
|
let item: AgentToolbarItemKind =
|
||||||
|
serde_json::from_str("\"ShareSession\"").expect("legacy setting should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(item, AgentToolbarItemKind::ShareSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn share_session_is_not_offered_by_defaults_or_configurator() {
|
||||||
|
assert!(!AgentToolbarItemKind::default_right().contains(&AgentToolbarItemKind::ShareSession));
|
||||||
|
assert!(!AgentToolbarItemKind::all_available().contains(&AgentToolbarItemKind::ShareSession));
|
||||||
|
}
|
||||||
+153
-39
@@ -30,6 +30,7 @@ use base64::Engine as _;
|
|||||||
use chrono::Duration;
|
use chrono::Duration;
|
||||||
use cli_controller::{CLISubagentController, CLISubagentEvent};
|
use cli_controller::{CLISubagentController, CLISubagentEvent};
|
||||||
use find::FindState;
|
use find::FindState;
|
||||||
|
use galaxy_agent_core::RuntimeActivityStatus;
|
||||||
use galaxy_core::features::FeatureFlag;
|
use galaxy_core::features::FeatureFlag;
|
||||||
use galaxy_core::ui::theme::color::internal_colors;
|
use galaxy_core::ui::theme::color::internal_colors;
|
||||||
use galaxy_core::ui::theme::Fill;
|
use galaxy_core::ui::theme::Fill;
|
||||||
@@ -818,6 +819,27 @@ impl CollapsibleElementState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_runtime_activity(&mut self, is_streaming: bool, is_finished: bool, has_output: bool) {
|
||||||
|
if is_streaming
|
||||||
|
&& has_output
|
||||||
|
&& !self.user_toggled_while_streaming
|
||||||
|
&& matches!(self.expansion_state, CollapsibleExpansionState::Collapsed)
|
||||||
|
{
|
||||||
|
self.expand();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.sync_finished_state(is_finished);
|
||||||
|
if is_finished {
|
||||||
|
if let CollapsibleExpansionState::Expanded {
|
||||||
|
scroll_pinned_to_bottom,
|
||||||
|
..
|
||||||
|
} = &mut self.expansion_state
|
||||||
|
{
|
||||||
|
*scroll_pinned_to_bottom = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Applies orchestration message display behavior after streaming finishes.
|
/// Applies orchestration message display behavior after streaming finishes.
|
||||||
fn finish_orchestration_message(&mut self, display_mode: OrchestrationMessageDisplayMode) {
|
fn finish_orchestration_message(&mut self, display_mode: OrchestrationMessageDisplayMode) {
|
||||||
let should_auto_collapse = self.should_auto_collapse_on_finish();
|
let should_auto_collapse = self.should_auto_collapse_on_finish();
|
||||||
@@ -2174,6 +2196,19 @@ impl AIBlock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
|
AIAgentAction {
|
||||||
|
id: action_id,
|
||||||
|
action: AIAgentActionType::RequestFileEdits { title, file_edits },
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
self.ensure_requested_edit_view(
|
||||||
|
action_id,
|
||||||
|
title,
|
||||||
|
file_edits.clone(),
|
||||||
|
output.server_output_id.clone(),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
AIAgentAction {
|
AIAgentAction {
|
||||||
id: action_id,
|
id: action_id,
|
||||||
action:
|
action:
|
||||||
@@ -2323,6 +2358,32 @@ impl AIBlock {
|
|||||||
|
|
||||||
// Register element state for reasoning messages and track summarization timing.
|
// Register element state for reasoning messages and track summarization timing.
|
||||||
for message in &output.messages {
|
for message in &output.messages {
|
||||||
|
if let AIAgentOutputMessageType::RuntimeActivity(activity) = &message.message {
|
||||||
|
let is_streaming = matches!(
|
||||||
|
activity.status,
|
||||||
|
Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress)
|
||||||
|
);
|
||||||
|
let is_finished = matches!(
|
||||||
|
activity.status,
|
||||||
|
Some(RuntimeActivityStatus::Completed | RuntimeActivityStatus::Failed)
|
||||||
|
);
|
||||||
|
let has_output = activity
|
||||||
|
.output
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|output| !output.is_empty());
|
||||||
|
let state = self
|
||||||
|
.collapsible_block_states
|
||||||
|
.entry(message.id.clone())
|
||||||
|
.or_insert_with(|| {
|
||||||
|
if is_streaming && has_output {
|
||||||
|
CollapsibleElementState::default()
|
||||||
|
} else {
|
||||||
|
CollapsibleElementState::collapsed()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.sync_runtime_activity(is_streaming, is_finished, has_output);
|
||||||
|
}
|
||||||
|
|
||||||
if let AIAgentOutputMessageType::Reasoning {
|
if let AIAgentOutputMessageType::Reasoning {
|
||||||
finished_duration, ..
|
finished_duration, ..
|
||||||
} = &message.message
|
} = &message.message
|
||||||
@@ -2608,6 +2669,7 @@ impl AIBlock {
|
|||||||
| AIAgentOutputMessageType::Reasoning { .. }
|
| AIAgentOutputMessageType::Reasoning { .. }
|
||||||
| AIAgentOutputMessageType::Summarization { .. }
|
| AIAgentOutputMessageType::Summarization { .. }
|
||||||
| AIAgentOutputMessageType::Subagent(_)
|
| AIAgentOutputMessageType::Subagent(_)
|
||||||
|
| AIAgentOutputMessageType::RuntimeActivity(_)
|
||||||
| AIAgentOutputMessageType::Action(_)
|
| AIAgentOutputMessageType::Action(_)
|
||||||
| AIAgentOutputMessageType::TodoOperation(_)
|
| AIAgentOutputMessageType::TodoOperation(_)
|
||||||
| AIAgentOutputMessageType::WebSearch(_)
|
| AIAgentOutputMessageType::WebSearch(_)
|
||||||
@@ -2712,7 +2774,7 @@ impl AIBlock {
|
|||||||
},
|
},
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
self.handle_requested_edit_complete(
|
self.ensure_requested_edit_view(
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
file_edits.clone(),
|
file_edits.clone(),
|
||||||
@@ -3232,7 +3294,7 @@ impl AIBlock {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_requested_edit_complete(
|
fn ensure_requested_edit_view(
|
||||||
&mut self,
|
&mut self,
|
||||||
action_id: &AIAgentActionId,
|
action_id: &AIAgentActionId,
|
||||||
title: &Option<String>,
|
title: &Option<String>,
|
||||||
@@ -3240,6 +3302,10 @@ impl AIBlock {
|
|||||||
server_output_id: Option<ServerOutputId>,
|
server_output_id: Option<ServerOutputId>,
|
||||||
ctx: &mut ViewContext<Self>,
|
ctx: &mut ViewContext<Self>,
|
||||||
) {
|
) {
|
||||||
|
if self.requested_edits.contains_key(action_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let identifiers = AIIdentifiers {
|
let identifiers = AIIdentifiers {
|
||||||
client_conversation_id: Some(self.client_ids.conversation_id),
|
client_conversation_id: Some(self.client_ids.conversation_id),
|
||||||
client_exchange_id: Some(self.client_ids.client_exchange_id),
|
client_exchange_id: Some(self.client_ids.client_exchange_id),
|
||||||
@@ -3295,14 +3361,6 @@ impl AIBlock {
|
|||||||
ctx,
|
ctx,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
let executor = self
|
|
||||||
.action_model
|
|
||||||
.as_ref(ctx)
|
|
||||||
.request_file_edits_executor(ctx);
|
|
||||||
executor.update(ctx, |executor, _| {
|
|
||||||
executor.register_requested_edits(action_id, &view);
|
|
||||||
});
|
|
||||||
|
|
||||||
// If the diff is being viewed in a shared session (read-only mode), populate diffs from the payload.
|
// If the diff is being viewed in a shared session (read-only mode), populate diffs from the payload.
|
||||||
if self.action_model.as_ref(ctx).is_view_only() {
|
if self.action_model.as_ref(ctx).is_view_only() {
|
||||||
let active_session = self.active_session.as_ref(ctx);
|
let active_session = self.active_session.as_ref(ctx);
|
||||||
@@ -3459,7 +3517,17 @@ impl AIBlock {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.requested_edits
|
self.requested_edits
|
||||||
.insert(action_id.clone(), RequestedEdit::new(view));
|
.insert(action_id.clone(), RequestedEdit::new(view.clone()));
|
||||||
|
let executor = self
|
||||||
|
.action_model
|
||||||
|
.as_ref(ctx)
|
||||||
|
.request_file_edits_executor(ctx);
|
||||||
|
executor.update(ctx, |executor, ctx| {
|
||||||
|
executor.register_requested_edits(action_id, &view, ctx);
|
||||||
|
});
|
||||||
|
self.action_model.update(ctx, |action_model, ctx| {
|
||||||
|
action_model.retry_not_ready_action(action_id, self.client_ids.conversation_id, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
if self.model.request_type(ctx).is_passive() {
|
if self.model.request_type(ctx).is_passive() {
|
||||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||||
@@ -3510,7 +3578,10 @@ impl AIBlock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set the state based on the action status from the action model
|
// Set the state based on the action status from the action model
|
||||||
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id);
|
let action_status = self
|
||||||
|
.action_model
|
||||||
|
.as_ref(ctx)
|
||||||
|
.get_action_status(self.client_ids.conversation_id, action_id);
|
||||||
|
|
||||||
let is_reverted = BlocklistAIHistoryModel::as_ref(ctx)
|
let is_reverted = BlocklistAIHistoryModel::as_ref(ctx)
|
||||||
.conversation(&self.client_ids.conversation_id)
|
.conversation(&self.client_ids.conversation_id)
|
||||||
@@ -3605,6 +3676,7 @@ impl AIBlock {
|
|||||||
RequestedCommandViewEvent::Accepted => {
|
RequestedCommandViewEvent::Accepted => {
|
||||||
self.action_model.update(ctx, |action_model, ctx| {
|
self.action_model.update(ctx, |action_model, ctx| {
|
||||||
action_model.handle_requested_command_accepted(
|
action_model.handle_requested_command_accepted(
|
||||||
|
self.client_ids.conversation_id,
|
||||||
action_id,
|
action_id,
|
||||||
view.as_ref(ctx).command_text().to_string(),
|
view.as_ref(ctx).command_text().to_string(),
|
||||||
ctx,
|
ctx,
|
||||||
@@ -3623,7 +3695,10 @@ impl AIBlock {
|
|||||||
RequestedCommandViewEvent::UpdatedExpansionState { is_expanded } => {
|
RequestedCommandViewEvent::UpdatedExpansionState { is_expanded } => {
|
||||||
// We only care about expansion state updates when the command
|
// We only care about expansion state updates when the command
|
||||||
// is running or finished (i.e. when it has a block).
|
// is running or finished (i.e. when it has a block).
|
||||||
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id);
|
let action_status = self
|
||||||
|
.action_model
|
||||||
|
.as_ref(ctx)
|
||||||
|
.get_action_status(self.client_ids.conversation_id, action_id);
|
||||||
let has_finished_command_block = {
|
let has_finished_command_block = {
|
||||||
let terminal_model = self.terminal_model.lock();
|
let terminal_model = self.terminal_model.lock();
|
||||||
terminal_model
|
terminal_model
|
||||||
@@ -3822,7 +3897,7 @@ impl AIBlock {
|
|||||||
if self
|
if self
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(ctx)
|
.as_ref(ctx)
|
||||||
.get_action_status(action_id)
|
.get_action_status(self.client_ids.conversation_id, action_id)
|
||||||
.is_some_and(|status| status.is_blocked())
|
.is_some_and(|status| status.is_blocked())
|
||||||
{
|
{
|
||||||
ctx.focus(&view);
|
ctx.focus(&view);
|
||||||
@@ -4206,7 +4281,10 @@ impl AIBlock {
|
|||||||
// but it's not incorrect to populate if it is, and we rely on this for
|
// but it's not incorrect to populate if it is, and we rely on this for
|
||||||
// for restored conversations because action model events don't re-fire
|
// for restored conversations because action model events don't re-fire
|
||||||
// after the view is created.
|
// after the view is created.
|
||||||
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id);
|
let action_status = self
|
||||||
|
.action_model
|
||||||
|
.as_ref(ctx)
|
||||||
|
.get_action_status(self.client_ids.conversation_id, action_id);
|
||||||
if let Some(view) = self.search_codebase_view.get(action_id) {
|
if let Some(view) = self.search_codebase_view.get(action_id) {
|
||||||
let files = if let Some(AIActionStatus::Finished(ref result)) = action_status {
|
let files = if let Some(AIActionStatus::Finished(ref result)) = action_status {
|
||||||
if let AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Success {
|
if let AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Success {
|
||||||
@@ -4640,7 +4718,11 @@ impl AIBlock {
|
|||||||
pub fn is_blocked_on_user_confirmation(&self, app: &AppContext) -> bool {
|
pub fn is_blocked_on_user_confirmation(&self, app: &AppContext) -> bool {
|
||||||
self.requested_action_ids
|
self.requested_action_ids
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|id| self.action_model.as_ref(app).get_action_status(id))
|
.filter_map(|id| {
|
||||||
|
self.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(self.client_ids.conversation_id, id)
|
||||||
|
})
|
||||||
.any(|status| status.is_blocked())
|
.any(|status| status.is_blocked())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4666,7 +4748,12 @@ impl AIBlock {
|
|||||||
ctx.subscribe_to_model(action_model, |me, action_model, event, ctx| {
|
ctx.subscribe_to_model(action_model, |me, action_model, event, ctx| {
|
||||||
let action_id = event.action_id();
|
let action_id = event.action_id();
|
||||||
|
|
||||||
if me.is_finished() || !me.requested_action_ids.contains(action_id) {
|
if event
|
||||||
|
.conversation_id()
|
||||||
|
.is_some_and(|conversation_id| conversation_id != me.client_ids.conversation_id)
|
||||||
|
|| me.is_finished()
|
||||||
|
|| !me.requested_action_ids.contains(action_id)
|
||||||
|
{
|
||||||
// Technically, this subscription should be unregistered after `is_finished` is
|
// Technically, this subscription should be unregistered after `is_finished` is
|
||||||
// set to true, but it seems that the callback is called once more after the `unsubscribe_to_model`
|
// set to true, but it seems that the callback is called once more after the `unsubscribe_to_model`
|
||||||
// call, so early return here if this is errantly being called.
|
// call, so early return here if this is errantly being called.
|
||||||
@@ -4674,7 +4761,7 @@ impl AIBlock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
BlocklistAIActionEvent::ExecutingAction(..) => {
|
BlocklistAIActionEvent::ExecutingAction { .. } => {
|
||||||
match &me.autonomy_setting_speedbump {
|
match &me.autonomy_setting_speedbump {
|
||||||
AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands {
|
AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands {
|
||||||
action_id: speedbump_action_id,
|
action_id: speedbump_action_id,
|
||||||
@@ -4744,7 +4831,7 @@ impl AIBlock {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(..) => {
|
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
|
||||||
ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation);
|
ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation);
|
||||||
}
|
}
|
||||||
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
|
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
|
||||||
@@ -4760,7 +4847,7 @@ impl AIBlock {
|
|||||||
{
|
{
|
||||||
let should_collapse = action_model
|
let should_collapse = action_model
|
||||||
.as_ref(ctx)
|
.as_ref(ctx)
|
||||||
.get_action_result(action_id)
|
.get_action_result(me.client_ids.conversation_id, action_id)
|
||||||
.is_none_or(|result| match &result.result {
|
.is_none_or(|result| match &result.result {
|
||||||
AIAgentActionResultType::RequestCommandOutput(
|
AIAgentActionResultType::RequestCommandOutput(
|
||||||
RequestCommandOutputResult::Completed { exit_code, .. },
|
RequestCommandOutputResult::Completed { exit_code, .. },
|
||||||
@@ -4775,7 +4862,9 @@ impl AIBlock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(view) = me.search_codebase_view.get(action_id) {
|
if let Some(view) = me.search_codebase_view.get(action_id) {
|
||||||
let new_status = action_model.as_ref(ctx).get_action_status(action_id);
|
let new_status = action_model
|
||||||
|
.as_ref(ctx)
|
||||||
|
.get_action_status(me.client_ids.conversation_id, action_id);
|
||||||
view.update(ctx, |view, ctx| {
|
view.update(ctx, |view, ctx| {
|
||||||
view.update_status(new_status);
|
view.update_status(new_status);
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
@@ -4784,7 +4873,9 @@ impl AIBlock {
|
|||||||
|
|
||||||
// Create subagent panel state for finished StartAgent actions
|
// Create subagent panel state for finished StartAgent actions
|
||||||
if let Some(AIActionStatus::Finished(result)) =
|
if let Some(AIActionStatus::Finished(result)) =
|
||||||
action_model.as_ref(ctx).get_action_status(action_id)
|
action_model
|
||||||
|
.as_ref(ctx)
|
||||||
|
.get_action_status(me.client_ids.conversation_id, action_id)
|
||||||
{
|
{
|
||||||
if let AIAgentActionResultType::StartAgent(
|
if let AIAgentActionResultType::StartAgent(
|
||||||
crate::ai::agent::StartAgentResult::Success { agent_id, .. },
|
crate::ai::agent::StartAgentResult::Success { agent_id, .. },
|
||||||
@@ -4806,7 +4897,11 @@ impl AIBlock {
|
|||||||
let action_statuses = me
|
let action_statuses = me
|
||||||
.requested_action_ids
|
.requested_action_ids
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|id| action_model.as_ref(ctx).get_action_status(id))
|
.filter_map(|id| {
|
||||||
|
action_model
|
||||||
|
.as_ref(ctx)
|
||||||
|
.get_action_status(me.client_ids.conversation_id, id)
|
||||||
|
})
|
||||||
.collect_vec();
|
.collect_vec();
|
||||||
|
|
||||||
// Detecting links on SearchCodebase tool call outputs
|
// Detecting links on SearchCodebase tool call outputs
|
||||||
@@ -4839,7 +4934,9 @@ impl AIBlock {
|
|||||||
view.update_render_read_file_args(
|
view.update_render_read_file_args(
|
||||||
&me.find_state,
|
&me.find_state,
|
||||||
files.clone(),
|
files.clone(),
|
||||||
action_model.as_ref(ctx).get_action_status(action_id),
|
action_model
|
||||||
|
.as_ref(ctx)
|
||||||
|
.get_action_status(me.client_ids.conversation_id, action_id),
|
||||||
);
|
);
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
})
|
})
|
||||||
@@ -4849,7 +4946,9 @@ impl AIBlock {
|
|||||||
|
|
||||||
// Open the AI document pane when documents are created or edited
|
// Open the AI document pane when documents are created or edited
|
||||||
if let Some(action_result) =
|
if let Some(action_result) =
|
||||||
action_model.as_ref(ctx).get_action_result(action_id)
|
action_model
|
||||||
|
.as_ref(ctx)
|
||||||
|
.get_action_result(me.client_ids.conversation_id, action_id)
|
||||||
{
|
{
|
||||||
match &action_result.result {
|
match &action_result.result {
|
||||||
AIAgentActionResultType::CreateDocuments(
|
AIAgentActionResultType::CreateDocuments(
|
||||||
@@ -4901,7 +5000,7 @@ impl AIBlock {
|
|||||||
}
|
}
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
BlocklistAIActionEvent::QueuedAction(action_id) => {
|
BlocklistAIActionEvent::QueuedAction { action_id, .. } => {
|
||||||
// Update search codebase view status when action is queued
|
// Update search codebase view status when action is queued
|
||||||
if let Some(view) = me.search_codebase_view.get(action_id) {
|
if let Some(view) = me.search_codebase_view.get(action_id) {
|
||||||
view.update(ctx, |view, ctx| {
|
view.update(ctx, |view, ctx| {
|
||||||
@@ -4929,7 +5028,8 @@ impl AIBlock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BlocklistAIActionEvent::InitProject(_)
|
BlocklistAIActionEvent::ToolLifecycle { .. }
|
||||||
|
| BlocklistAIActionEvent::InitProject(_)
|
||||||
| BlocklistAIActionEvent::ToggleCodeReview(_) => {}
|
| BlocklistAIActionEvent::ToggleCodeReview(_) => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -5608,7 +5708,9 @@ impl AIBlock {
|
|||||||
/// This hides their keybindings in the UI and makes them less interactive.
|
/// This hides their keybindings in the UI and makes them less interactive.
|
||||||
pub fn ignore_passive_actions(&mut self, ctx: &mut ViewContext<Self>) {
|
pub fn ignore_passive_actions(&mut self, ctx: &mut ViewContext<Self>) {
|
||||||
self.action_model.update(ctx, |action_model, ctx| {
|
self.action_model.update(ctx, |action_model, ctx| {
|
||||||
for action in action_model.get_pending_actions() {
|
for action in
|
||||||
|
action_model.get_pending_actions_for_conversation(&self.client_ids.conversation_id)
|
||||||
|
{
|
||||||
if let Some(edit) = self.requested_edits.get(&action.id) {
|
if let Some(edit) = self.requested_edits.get(&action.id) {
|
||||||
edit.view.update(ctx, |view, ctx| view.dismiss(ctx));
|
edit.view.update(ctx, |view, ctx| view.dismiss(ctx));
|
||||||
} else if let Some(suggested_prompt) = self.unit_tests_suggestions.get(&action.id) {
|
} else if let Some(suggested_prompt) = self.unit_tests_suggestions.get(&action.id) {
|
||||||
@@ -5661,7 +5763,12 @@ impl AIBlock {
|
|||||||
.view
|
.view
|
||||||
.update(ctx, |view, ctx| view.commit_and_get_command_text(ctx));
|
.update(ctx, |view, ctx| view.commit_and_get_command_text(ctx));
|
||||||
self.action_model.update(ctx, |action_model, ctx| {
|
self.action_model.update(ctx, |action_model, ctx| {
|
||||||
action_model.handle_requested_command_accepted(&action_id, command_text, ctx);
|
action_model.handle_requested_command_accepted(
|
||||||
|
self.client_ids.conversation_id,
|
||||||
|
&action_id,
|
||||||
|
command_text,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
@@ -5689,12 +5796,11 @@ impl AIBlock {
|
|||||||
/// Finds the undismissed passive code diff across all pending actions.
|
/// Finds the undismissed passive code diff across all pending actions.
|
||||||
/// This is needed because passive code diffs are NOT added to the active conversation by default, when they first appear.
|
/// This is needed because passive code diffs are NOT added to the active conversation by default, when they first appear.
|
||||||
pub(crate) fn find_undismissed_code_diff(&self, app: &AppContext) -> Option<&RequestedEdit> {
|
pub(crate) fn find_undismissed_code_diff(&self, app: &AppContext) -> Option<&RequestedEdit> {
|
||||||
let all_pending_actions = self.action_model.as_ref(app).get_pending_actions();
|
|
||||||
|
|
||||||
// Find any RequestFileEdits action that has a corresponding passive code diff view.
|
// Find any RequestFileEdits action that has a corresponding passive code diff view.
|
||||||
// Note that we only expect a maximum of 1 passive code diff to be undismissed at any given time.
|
// Note that we only expect a maximum of 1 passive code diff to be undismissed at any given time.
|
||||||
all_pending_actions
|
self.action_model
|
||||||
.iter()
|
.as_ref(app)
|
||||||
|
.get_pending_actions_for_conversation(&self.client_ids.conversation_id)
|
||||||
.find_map(|action| match &action.action {
|
.find_map(|action| match &action.action {
|
||||||
AIAgentActionType::RequestFileEdits {
|
AIAgentActionType::RequestFileEdits {
|
||||||
file_edits: _,
|
file_edits: _,
|
||||||
@@ -5734,7 +5840,10 @@ impl AIBlock {
|
|||||||
.is_none_or(|output| {
|
.is_none_or(|output| {
|
||||||
output.get().actions().last().is_none_or(|action| {
|
output.get().actions().last().is_none_or(|action| {
|
||||||
let is_streaming = self.model.status(app).is_streaming();
|
let is_streaming = self.model.status(app).is_streaming();
|
||||||
let status = self.action_model.as_ref(app).get_action_status(&action.id);
|
let status = self
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(self.client_ids.conversation_id, &action.id);
|
||||||
is_streaming || status.is_some_and(|status| status.is_running())
|
is_streaming || status.is_some_and(|status| status.is_running())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -5761,7 +5870,7 @@ impl AIBlock {
|
|||||||
.any(|(action_id, requested_command)| {
|
.any(|(action_id, requested_command)| {
|
||||||
self.action_model
|
self.action_model
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.get_action_status(action_id)
|
.get_action_status(self.client_ids.conversation_id, action_id)
|
||||||
.is_some_and(|status| status.is_running())
|
.is_some_and(|status| status.is_running())
|
||||||
&& requested_command.view.as_ref(app).is_header_expanded()
|
&& requested_command.view.as_ref(app).is_header_expanded()
|
||||||
})
|
})
|
||||||
@@ -5861,7 +5970,10 @@ impl AIBlock {
|
|||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
let output = output.get();
|
let output = output.get();
|
||||||
output.format_for_copy(Some(self.action_model.as_ref(app)))
|
output.format_for_copy_for_conversation(
|
||||||
|
Some(self.action_model.as_ref(app)),
|
||||||
|
Some(self.client_ids.conversation_id),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets AI output text for copying from the preceding user query until the next user query
|
/// Gets AI output text for copying from the preceding user query until the next user query
|
||||||
@@ -5916,8 +6028,10 @@ impl AIBlock {
|
|||||||
// Collect all AI outputs from start_idx to end_idx (exclusive)
|
// Collect all AI outputs from start_idx to end_idx (exclusive)
|
||||||
let mut combined_result = Vec::new();
|
let mut combined_result = Vec::new();
|
||||||
for exchange in exchanges.iter().take(end_idx).skip(start_idx) {
|
for exchange in exchanges.iter().take(end_idx).skip(start_idx) {
|
||||||
let formatted_output =
|
let formatted_output = exchange.format_output_for_copy_for_conversation(
|
||||||
exchange.format_output_for_copy(Some(self.action_model.as_ref(app)));
|
Some(self.action_model.as_ref(app)),
|
||||||
|
Some(self.client_ids.conversation_id),
|
||||||
|
);
|
||||||
if !formatted_output.is_empty() {
|
if !formatted_output.is_empty() {
|
||||||
combined_result.push(formatted_output);
|
combined_result.push(formatted_output);
|
||||||
}
|
}
|
||||||
@@ -7089,7 +7203,7 @@ impl TypedActionView for AIBlock {
|
|||||||
let Some(result) = self
|
let Some(result) = self
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(ctx)
|
.as_ref(ctx)
|
||||||
.get_action_result(action_id)
|
.get_action_result(self.client_ids.conversation_id, action_id)
|
||||||
.map(Arc::clone)
|
.map(Arc::clone)
|
||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1170,7 +1170,7 @@ impl View for CLISubagentView {
|
|||||||
let is_cancelled = self
|
let is_cancelled = self
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.get_action_status(&action.id)
|
.get_action_status(self.conversation_id, &action.id)
|
||||||
.is_some_and(|status| status.is_cancelled());
|
.is_some_and(|status| status.is_cancelled());
|
||||||
if blocked_action.is_none() && !is_cancelled && !should_hide_responses {
|
if blocked_action.is_none() && !is_cancelled && !should_hide_responses {
|
||||||
if let Some(rendered_action) = render_action(action.action.clone(), app)
|
if let Some(rendered_action) = render_action(action.action.clone(), app)
|
||||||
@@ -1641,7 +1641,9 @@ fn should_retain_task_output_message(
|
|||||||
|| (is_latest_exchange
|
|| (is_latest_exchange
|
||||||
&& matches!(
|
&& matches!(
|
||||||
message,
|
message,
|
||||||
AIAgentOutputMessageType::Action(_) | AIAgentOutputMessageType::WebSearch(_)
|
AIAgentOutputMessageType::Action(_)
|
||||||
|
| AIAgentOutputMessageType::RuntimeActivity(_)
|
||||||
|
| AIAgentOutputMessageType::WebSearch(_)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::ai::agent::{
|
|||||||
};
|
};
|
||||||
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
|
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
|
||||||
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
|
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
|
||||||
|
use crate::ai::blocklist::controller::PendingProviderCommandCompletion;
|
||||||
use crate::ai::blocklist::{
|
use crate::ai::blocklist::{
|
||||||
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
|
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
|
||||||
BlocklistAIControllerEvent, BlocklistAIHistoryEvent,
|
BlocklistAIControllerEvent, BlocklistAIHistoryEvent,
|
||||||
@@ -40,9 +41,14 @@ pub enum UserTakeOverReason {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
struct ActiveCLISubagentState {
|
struct ActiveCLISubagentState {
|
||||||
|
initial_requested_command_conversation_id: Option<AIConversationId>,
|
||||||
initial_requested_command_action_id: Option<AIAgentActionId>,
|
initial_requested_command_action_id: Option<AIAgentActionId>,
|
||||||
task_id: Option<TaskId>,
|
task_id: Option<TaskId>,
|
||||||
last_snapshot_at: Option<Instant>,
|
last_snapshot_at: Option<Instant>,
|
||||||
|
/// Prevents a monitor turn that ended with prose and no tool call from recursively
|
||||||
|
/// generating nudges. A real snapshot/action result resets this so the next turn can be
|
||||||
|
/// nudged again if it stalls in the same way.
|
||||||
|
monitor_nudge_sent: bool,
|
||||||
completion: Option<PendingCommandCompletion>,
|
completion: Option<PendingCommandCompletion>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +58,7 @@ struct PendingCommandCompletion {
|
|||||||
initial_requested_command_action_id: Option<AIAgentActionId>,
|
initial_requested_command_action_id: Option<AIAgentActionId>,
|
||||||
prompt: String,
|
prompt: String,
|
||||||
completed_command: RunningCommand,
|
completed_command: RunningCommand,
|
||||||
|
exit_code: i32,
|
||||||
final_turn_started: bool,
|
final_turn_started: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +74,7 @@ impl UserTakeOverReason {
|
|||||||
pub fn transfer_reason(&self) -> Option<&str> {
|
pub fn transfer_reason(&self) -> Option<&str> {
|
||||||
match self {
|
match self {
|
||||||
Self::TransferFromAgent { reason } => Some(reason.as_str()),
|
Self::TransferFromAgent { reason } => Some(reason.as_str()),
|
||||||
_ => None,
|
Self::Manual | Self::Stop => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,12 +168,25 @@ impl CLISubagentController {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
me.advance_completed_subagents(*conversation_id, ctx);
|
me.advance_completed_subagents(*conversation_id, ctx);
|
||||||
|
me.ensure_monitor_continues(*conversation_id, ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
|
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
|
||||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => {
|
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
|
||||||
|
action_id,
|
||||||
|
conversation_id,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
let mut terminal_model = me.terminal_model.lock();
|
let mut terminal_model = me.terminal_model.lock();
|
||||||
let active_block = terminal_model.block_list_mut().active_block_mut();
|
let active_block = terminal_model.block_list_mut().active_block_mut();
|
||||||
|
if !matches_active_requested_command(
|
||||||
|
*conversation_id,
|
||||||
|
action_id,
|
||||||
|
active_block.ai_conversation_id(),
|
||||||
|
active_block.requested_command_action_id(),
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
active_block.update_is_agent_blocked(true);
|
active_block.update_is_agent_blocked(true);
|
||||||
|
|
||||||
let action_id = active_block.requested_command_action_id().cloned();
|
let action_id = active_block.requested_command_action_id().cloned();
|
||||||
@@ -176,9 +196,21 @@ impl CLISubagentController {
|
|||||||
agent_has_control: active_block.is_agent_in_control(),
|
agent_has_control: active_block.is_agent_in_control(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
BlocklistAIActionEvent::ExecutingAction(..) => {
|
BlocklistAIActionEvent::ExecutingAction {
|
||||||
|
action_id,
|
||||||
|
conversation_id,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
let mut terminal_model = me.terminal_model.lock();
|
let mut terminal_model = me.terminal_model.lock();
|
||||||
let active_block = terminal_model.block_list_mut().active_block_mut();
|
let active_block = terminal_model.block_list_mut().active_block_mut();
|
||||||
|
if !matches_active_requested_command(
|
||||||
|
*conversation_id,
|
||||||
|
action_id,
|
||||||
|
active_block.ai_conversation_id(),
|
||||||
|
active_block.requested_command_action_id(),
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
active_block.update_is_agent_blocked(false);
|
active_block.update_is_agent_blocked(false);
|
||||||
|
|
||||||
let action_id = active_block.requested_command_action_id().cloned();
|
let action_id = active_block.requested_command_action_id().cloned();
|
||||||
@@ -190,12 +222,13 @@ impl CLISubagentController {
|
|||||||
}
|
}
|
||||||
BlocklistAIActionEvent::FinishedAction {
|
BlocklistAIActionEvent::FinishedAction {
|
||||||
action_id: finished_action_id,
|
action_id: finished_action_id,
|
||||||
|
conversation_id,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let action_result = me
|
let action_result = me
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(ctx)
|
.as_ref(ctx)
|
||||||
.get_action_result(finished_action_id);
|
.get_action_result(*conversation_id, finished_action_id);
|
||||||
let initial_command_finished_without_snapshot =
|
let initial_command_finished_without_snapshot =
|
||||||
action_result.is_some_and(|result| {
|
action_result.is_some_and(|result| {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -215,38 +248,47 @@ impl CLISubagentController {
|
|||||||
.cloned();
|
.cloned();
|
||||||
let mut terminal_model = me.terminal_model.lock();
|
let mut terminal_model = me.terminal_model.lock();
|
||||||
let active_block = terminal_model.block_list_mut().active_block_mut();
|
let active_block = terminal_model.block_list_mut().active_block_mut();
|
||||||
active_block.update_is_agent_blocked(false);
|
if matches_active_requested_command(
|
||||||
|
*conversation_id,
|
||||||
|
finished_action_id,
|
||||||
|
active_block.ai_conversation_id(),
|
||||||
|
active_block.requested_command_action_id(),
|
||||||
|
) {
|
||||||
|
active_block.update_is_agent_blocked(false);
|
||||||
|
|
||||||
let active_command_action_id = active_block.requested_command_action_id().cloned();
|
let active_command_action_id =
|
||||||
ctx.emit(CLISubagentEvent::UpdatedControl {
|
active_block.requested_command_action_id().cloned();
|
||||||
block_id: active_block.id().clone(),
|
ctx.emit(CLISubagentEvent::UpdatedControl {
|
||||||
requested_command_action_id: active_command_action_id,
|
block_id: active_block.id().clone(),
|
||||||
agent_has_control: active_block.is_agent_in_control(),
|
requested_command_action_id: active_command_action_id,
|
||||||
});
|
agent_has_control: active_block.is_agent_in_control(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Updates the last snapshot timestamp for the active block after the agent has read the block output.
|
// Updates the last snapshot timestamp for the active block after the agent has read the block output.
|
||||||
if let Some(snapshot_block_id) = snapshot_block_id {
|
if let Some(snapshot_block_id) = snapshot_block_id {
|
||||||
me.active_subagents_by_block
|
let state = me
|
||||||
|
.active_subagents_by_block
|
||||||
.entry(snapshot_block_id.clone())
|
.entry(snapshot_block_id.clone())
|
||||||
.or_default()
|
.or_default();
|
||||||
.last_snapshot_at = Some(Instant::now());
|
state.last_snapshot_at = Some(Instant::now());
|
||||||
|
state.monitor_nudge_sent = false;
|
||||||
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
|
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
|
||||||
}
|
}
|
||||||
if initial_command_finished_without_snapshot {
|
if initial_command_finished_without_snapshot {
|
||||||
me.active_subagents_by_block.retain(|_, state| {
|
me.active_subagents_by_block.retain(|_, state| {
|
||||||
state.task_id.is_some()
|
state.task_id.is_some()
|
||||||
|| state.initial_requested_command_action_id.as_ref()
|
|| !matches_requested_command_identity(
|
||||||
!= Some(finished_action_id)
|
*conversation_id,
|
||||||
|
finished_action_id,
|
||||||
|
state.initial_requested_command_conversation_id,
|
||||||
|
state.initial_requested_command_action_id.as_ref(),
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
drop(terminal_model);
|
||||||
if let Some(block_id) = command_finished_block_id {
|
if let Some(block_id) = command_finished_block_id {
|
||||||
if let Some(completion) = me
|
me.advance_completed_subagent(&block_id, ctx);
|
||||||
.active_subagents_by_block
|
|
||||||
.get_mut(&block_id)
|
|
||||||
.and_then(|state| state.completion.as_mut())
|
|
||||||
{
|
|
||||||
completion.final_turn_started = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
@@ -265,6 +307,8 @@ impl CLISubagentController {
|
|||||||
let block_id = block.id().clone();
|
let block_id = block.id().clone();
|
||||||
let conversation_id = block.ai_conversation_id();
|
let conversation_id = block.ai_conversation_id();
|
||||||
let requested_command_action_id = block.requested_command_action_id().cloned();
|
let requested_command_action_id = block.requested_command_action_id().cloned();
|
||||||
|
let should_skip_completion_assessment =
|
||||||
|
!should_request_completion_assessment(block.long_running_control_state());
|
||||||
let completion = match (&block_completed_event.block_type, conversation_id) {
|
let completion = match (&block_completed_event.block_type, conversation_id) {
|
||||||
(BlockType::User(completed), Some(conversation_id)) => {
|
(BlockType::User(completed), Some(conversation_id)) => {
|
||||||
let command = if completed.command_with_obfuscated_secrets.is_empty() {
|
let command = if completed.command_with_obfuscated_secrets.is_empty() {
|
||||||
@@ -294,6 +338,7 @@ impl CLISubagentController {
|
|||||||
requested_command_id: requested_command_action_id.clone(),
|
requested_command_id: requested_command_action_id.clone(),
|
||||||
is_alt_screen_active: false,
|
is_alt_screen_active: false,
|
||||||
},
|
},
|
||||||
|
exit_code,
|
||||||
final_turn_started: false,
|
final_turn_started: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -310,17 +355,70 @@ impl CLISubagentController {
|
|||||||
};
|
};
|
||||||
drop(terminal_model);
|
drop(terminal_model);
|
||||||
|
|
||||||
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id) else {
|
let provider_accepted_completion = completion.as_ref().is_some_and(|completion| {
|
||||||
return;
|
let provider_completion = PendingProviderCommandCompletion::new(
|
||||||
};
|
completion.completed_command.block_id.clone(),
|
||||||
if subagent_state.last_snapshot_at.is_some() {
|
completion.initial_requested_command_action_id.clone(),
|
||||||
|
completion.completed_command.command.clone(),
|
||||||
|
completion.completed_command.grid_contents.clone(),
|
||||||
|
completion.exit_code,
|
||||||
|
);
|
||||||
|
me.controller.update(ctx, |controller, ctx| {
|
||||||
|
controller.offer_provider_command_completion(
|
||||||
|
completion.conversation_id,
|
||||||
|
provider_completion,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let has_last_snapshot = me
|
||||||
|
.active_subagents_by_block
|
||||||
|
.get(&block_id)
|
||||||
|
.is_some_and(|state| state.last_snapshot_at.is_some());
|
||||||
|
if has_last_snapshot {
|
||||||
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
|
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
|
||||||
}
|
}
|
||||||
subagent_state.completion = completion;
|
if provider_accepted_completion {
|
||||||
if subagent_state.completion.is_none() {
|
// The provider controller owns deactivation after it applies the queued
|
||||||
|
// completion at a safe run boundary.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !me.active_subagents_by_block.contains_key(&block_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Stop takeover intentionally cancels the subagent. The command may still
|
||||||
|
// finish later, but that completion must not start a new assessment turn. Also
|
||||||
|
// clean up the in-memory monitor state so the stopped subagent cannot linger in
|
||||||
|
// the UI or intercept later refreshes.
|
||||||
|
if should_skip_completion_assessment {
|
||||||
|
me.finish_subagent(
|
||||||
|
&block_id,
|
||||||
|
conversation_id,
|
||||||
|
requested_command_action_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_completion = {
|
||||||
|
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
subagent_state.completion = completion;
|
||||||
|
subagent_state.completion.is_some()
|
||||||
|
};
|
||||||
|
if !has_completion {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"CLI monitor block {block_id:?} completed without final command metadata"
|
"CLI monitor block {block_id:?} completed without final command metadata"
|
||||||
);
|
);
|
||||||
|
me.finish_subagent(
|
||||||
|
&block_id,
|
||||||
|
conversation_id,
|
||||||
|
requested_command_action_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
me.advance_completed_subagent(&block_id, ctx);
|
me.advance_completed_subagent(&block_id, ctx);
|
||||||
@@ -359,10 +457,10 @@ impl CLISubagentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn advance_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
|
fn advance_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
|
||||||
let Some((task_id, completion)) = self
|
let Some(completion) = self
|
||||||
.active_subagents_by_block
|
.active_subagents_by_block
|
||||||
.get(block_id)
|
.get(block_id)
|
||||||
.and_then(|state| Some((state.task_id.clone()?, state.completion.as_ref()?.clone())))
|
.and_then(|state| state.completion.as_ref().cloned())
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -380,14 +478,18 @@ impl CLISubagentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if completion.final_turn_started {
|
if completion.final_turn_started {
|
||||||
self.finish_completed_subagent(block_id, ctx);
|
self.finish_subagent(
|
||||||
|
block_id,
|
||||||
|
Some(completion.conversation_id),
|
||||||
|
completion.initial_requested_command_action_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sent = self.controller.update(ctx, |controller, ctx| {
|
let sent = self.controller.update(ctx, |controller, ctx| {
|
||||||
controller.send_command_completion_assessment(
|
controller.send_command_completion_assessment(
|
||||||
completion.conversation_id,
|
completion.conversation_id,
|
||||||
task_id,
|
|
||||||
completion.prompt,
|
completion.prompt,
|
||||||
completion.completed_command,
|
completion.completed_command,
|
||||||
ctx,
|
ctx,
|
||||||
@@ -404,38 +506,125 @@ impl CLISubagentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn finish_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
|
/// A monitor turn that returns only prose has no action result to trigger the normal
|
||||||
let Some(state) = self.active_subagents_by_block.remove(block_id) else {
|
/// action-follow-up path. Nudge that monitor once with the live command context so a model
|
||||||
return;
|
/// that acknowledged the first snapshot without polling gets another chance to inspect it.
|
||||||
};
|
fn ensure_monitor_continues(
|
||||||
let Some(completion) = state.completion else {
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
let Some(block_id) = BlocklistAIHistoryModel::as_ref(ctx)
|
||||||
|
.conversation(&conversation_id)
|
||||||
|
.and_then(|conversation| {
|
||||||
|
conversation.all_tasks().find_map(|task| {
|
||||||
|
let block_id = task.cli_subagent_block_id()?;
|
||||||
|
let state = self.active_subagents_by_block.get(&block_id)?;
|
||||||
|
if state.task_id.as_ref() != Some(task.id()) || state.completion.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let last_exchange_has_action = task.last_exchange().is_some_and(|exchange| {
|
||||||
|
exchange
|
||||||
|
.output_status
|
||||||
|
.output()
|
||||||
|
.is_some_and(|output| output.get().actions().next().is_some())
|
||||||
|
});
|
||||||
|
should_nudge_monitor_turn(last_exchange_has_action, state.monitor_nudge_sent)
|
||||||
|
.then_some(block_id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let deactivate_result =
|
if self
|
||||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
|
.controller
|
||||||
history_model.deactivate_cli_subagent_task_for_conversation(
|
.as_ref(ctx)
|
||||||
block_id,
|
.has_active_provider_run(conversation_id)
|
||||||
completion.conversation_id,
|
|| self
|
||||||
)
|
.controller
|
||||||
});
|
.as_ref(ctx)
|
||||||
if let Err(error) = deactivate_result {
|
.has_active_stream_for_conversation(conversation_id, ctx)
|
||||||
log::error!(
|
|| self
|
||||||
"Failed to deactivate completed CLI monitor for block {block_id:?}: {error:?}"
|
.action_model
|
||||||
);
|
.as_ref(ctx)
|
||||||
|
.has_unfinished_actions_for_conversation(conversation_id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let command_is_still_agent_controlled = {
|
||||||
|
let terminal_model = self.terminal_model.lock();
|
||||||
|
terminal_model
|
||||||
|
.block_list()
|
||||||
|
.block_with_id(&block_id)
|
||||||
|
.is_some_and(|block| {
|
||||||
|
block.is_active_and_long_running()
|
||||||
|
&& block.is_agent_in_control()
|
||||||
|
&& block.ai_conversation_id() == Some(conversation_id)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if !command_is_still_agent_controlled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(state) = self.active_subagents_by_block.get_mut(&block_id) {
|
||||||
|
state.monitor_nudge_sent = true;
|
||||||
|
}
|
||||||
|
self.controller.update(ctx, |controller, ctx| {
|
||||||
|
controller.send_cli_monitor_nudge(conversation_id, ctx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_subagent(
|
||||||
|
&mut self,
|
||||||
|
block_id: &BlockId,
|
||||||
|
conversation_id: Option<AIConversationId>,
|
||||||
|
initial_requested_command_action_id: Option<AIAgentActionId>,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
let Some(state) = self.active_subagents_by_block.remove(block_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let conversation_id = conversation_id.or_else(|| {
|
||||||
|
state
|
||||||
|
.completion
|
||||||
|
.as_ref()
|
||||||
|
.map(|completion| completion.conversation_id)
|
||||||
|
});
|
||||||
|
let initial_requested_command_action_id = initial_requested_command_action_id
|
||||||
|
.or_else(|| {
|
||||||
|
state
|
||||||
|
.completion
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|completion| completion.initial_requested_command_action_id.clone())
|
||||||
|
})
|
||||||
|
.or(state.initial_requested_command_action_id);
|
||||||
|
|
||||||
|
if let Some(conversation_id) = conversation_id {
|
||||||
|
let deactivate_result =
|
||||||
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
|
||||||
|
history_model
|
||||||
|
.deactivate_cli_subagent_task_for_conversation(block_id, conversation_id)
|
||||||
|
});
|
||||||
|
if let Err(error) = deactivate_result {
|
||||||
|
log::error!("Failed to deactivate CLI monitor for block {block_id:?}: {error:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.emit(CLISubagentEvent::FinishedSubagent {
|
ctx.emit(CLISubagentEvent::FinishedSubagent {
|
||||||
block_id: block_id.clone(),
|
block_id: block_id.clone(),
|
||||||
conversation_id: Some(completion.conversation_id),
|
conversation_id,
|
||||||
initial_requested_command_action_id: completion.initial_requested_command_action_id,
|
initial_requested_command_action_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(agent_view_controller) = &self.agent_view_controller {
|
if let (Some(agent_view_controller), Some(conversation_id)) =
|
||||||
|
(&self.agent_view_controller, conversation_id)
|
||||||
|
{
|
||||||
agent_view_controller.update(ctx, |controller, ctx| {
|
agent_view_controller.update(ctx, |controller, ctx| {
|
||||||
let is_this_inline_conversation = controller.is_inline()
|
let is_this_inline_conversation = controller.is_inline()
|
||||||
&& controller.agent_view_state().active_conversation_id()
|
&& controller.agent_view_state().active_conversation_id()
|
||||||
== Some(completion.conversation_id);
|
== Some(conversation_id);
|
||||||
if is_this_inline_conversation {
|
if is_this_inline_conversation {
|
||||||
controller.exit_agent_view(ctx);
|
controller.exit_agent_view(ctx);
|
||||||
}
|
}
|
||||||
@@ -469,11 +658,18 @@ impl CLISubagentController {
|
|||||||
///
|
///
|
||||||
/// The placeholder lets command completion and action-result events arrive in either order
|
/// The placeholder lets command completion and action-result events arrive in either order
|
||||||
/// without losing the completion that a subsequently-created CLI monitor needs.
|
/// without losing the completion that a subsequently-created CLI monitor needs.
|
||||||
pub fn track_requested_command(&mut self, block_id: &BlockId, action_id: &AIAgentActionId) {
|
pub fn track_requested_command(
|
||||||
self.active_subagents_by_block
|
&mut self,
|
||||||
|
block_id: &BlockId,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
action_id: &AIAgentActionId,
|
||||||
|
) {
|
||||||
|
let state = self
|
||||||
|
.active_subagents_by_block
|
||||||
.entry(block_id.clone())
|
.entry(block_id.clone())
|
||||||
.or_default()
|
.or_default();
|
||||||
.initial_requested_command_action_id = Some(action_id.clone());
|
state.initial_requested_command_conversation_id = Some(conversation_id);
|
||||||
|
state.initial_requested_command_action_id = Some(action_id.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force the currently in-flight poll for the given long-running command block to
|
/// Force the currently in-flight poll for the given long-running command block to
|
||||||
@@ -609,13 +805,7 @@ impl CLISubagentController {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
self.controller.update(ctx, |controller, ctx| {
|
self.controller.update(ctx, |controller, ctx| {
|
||||||
controller.resume_conversation(
|
controller.resume_conversation(conversation_id, resume_context, ctx);
|
||||||
conversation_id,
|
|
||||||
/*can_attempt_resume_on_error*/ true,
|
|
||||||
/*is_auto_resume_after_error*/ false,
|
|
||||||
resume_context,
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -726,6 +916,10 @@ impl CLISubagentController {
|
|||||||
requested_command_action_id: action_id.clone(),
|
requested_command_action_id: action_id.clone(),
|
||||||
agent_has_control,
|
agent_has_control,
|
||||||
});
|
});
|
||||||
|
self.active_subagents_by_block
|
||||||
|
.entry(block_id.clone())
|
||||||
|
.or_default()
|
||||||
|
.initial_requested_command_conversation_id = Some(conversation_id);
|
||||||
self.active_subagents_by_block
|
self.active_subagents_by_block
|
||||||
.entry(block_id.clone())
|
.entry(block_id.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
@@ -874,6 +1068,7 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
|
|||||||
AIAgentActionResultType::RequestCommandOutput(
|
AIAgentActionResultType::RequestCommandOutput(
|
||||||
RequestCommandOutputResult::LongRunningCommandSnapshot { .. }
|
RequestCommandOutputResult::LongRunningCommandSnapshot { .. }
|
||||||
| RequestCommandOutputResult::CancelledBeforeExecution
|
| RequestCommandOutputResult::CancelledBeforeExecution
|
||||||
|
| RequestCommandOutputResult::ExecutionError { .. }
|
||||||
| RequestCommandOutputResult::Denylisted { .. },
|
| RequestCommandOutputResult::Denylisted { .. },
|
||||||
)
|
)
|
||||||
| AIAgentActionResultType::WriteToLongRunningShellCommand(
|
| AIAgentActionResultType::WriteToLongRunningShellCommand(
|
||||||
@@ -919,3 +1114,120 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
|
|||||||
| AIAgentActionResultType::WaitForEvents(_) => None,
|
| AIAgentActionResultType::WaitForEvents(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_request_completion_assessment(
|
||||||
|
control_state: Option<&LongRunningCommandControlState>,
|
||||||
|
) -> bool {
|
||||||
|
!control_state
|
||||||
|
.and_then(LongRunningCommandControlState::user_take_over_reason)
|
||||||
|
.is_some_and(UserTakeOverReason::is_stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_nudge_monitor_turn(last_exchange_has_action: bool, monitor_nudge_sent: bool) -> bool {
|
||||||
|
!last_exchange_has_action && !monitor_nudge_sent
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_active_requested_command(
|
||||||
|
event_conversation_id: AIConversationId,
|
||||||
|
event_action_id: &AIAgentActionId,
|
||||||
|
active_conversation_id: Option<AIConversationId>,
|
||||||
|
active_requested_command_id: Option<&AIAgentActionId>,
|
||||||
|
) -> bool {
|
||||||
|
active_conversation_id == Some(event_conversation_id)
|
||||||
|
&& active_requested_command_id == Some(event_action_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_requested_command_identity(
|
||||||
|
event_conversation_id: AIConversationId,
|
||||||
|
event_action_id: &AIAgentActionId,
|
||||||
|
requested_command_conversation_id: Option<AIConversationId>,
|
||||||
|
requested_command_action_id: Option<&AIAgentActionId>,
|
||||||
|
) -> bool {
|
||||||
|
requested_command_conversation_id == Some(event_conversation_id)
|
||||||
|
&& requested_command_action_id == Some(event_action_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_takeover_does_not_request_a_completion_assessment() {
|
||||||
|
let state = LongRunningCommandControlState::User {
|
||||||
|
reason: UserTakeOverReason::Stop,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!should_request_completion_assessment(Some(&state)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_stop_control_states_can_request_a_completion_assessment() {
|
||||||
|
let agent_state = LongRunningCommandControlState::Agent {
|
||||||
|
is_blocked: false,
|
||||||
|
should_hide_responses: false,
|
||||||
|
};
|
||||||
|
let transfer_state = LongRunningCommandControlState::User {
|
||||||
|
reason: UserTakeOverReason::TransferFromAgent {
|
||||||
|
reason: "needs user input".to_owned(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(should_request_completion_assessment(None));
|
||||||
|
assert!(should_request_completion_assessment(Some(&agent_state)));
|
||||||
|
assert!(should_request_completion_assessment(Some(&transfer_state)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prose_monitor_turn_is_nudged_once_until_a_tool_action_runs() {
|
||||||
|
assert!(should_nudge_monitor_turn(false, false));
|
||||||
|
assert!(!should_nudge_monitor_turn(false, true));
|
||||||
|
assert!(!should_nudge_monitor_turn(true, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_control_event_must_match_conversation_and_requested_command() {
|
||||||
|
let active_conversation_id = AIConversationId::new();
|
||||||
|
let other_conversation_id = AIConversationId::new();
|
||||||
|
let active_action_id = AIAgentActionId::from("same-action".to_owned());
|
||||||
|
let other_action_id = AIAgentActionId::from("other-action".to_owned());
|
||||||
|
|
||||||
|
assert!(matches_active_requested_command(
|
||||||
|
active_conversation_id,
|
||||||
|
&active_action_id,
|
||||||
|
Some(active_conversation_id),
|
||||||
|
Some(&active_action_id),
|
||||||
|
));
|
||||||
|
assert!(!matches_active_requested_command(
|
||||||
|
other_conversation_id,
|
||||||
|
&active_action_id,
|
||||||
|
Some(active_conversation_id),
|
||||||
|
Some(&active_action_id),
|
||||||
|
));
|
||||||
|
assert!(!matches_active_requested_command(
|
||||||
|
active_conversation_id,
|
||||||
|
&other_action_id,
|
||||||
|
Some(active_conversation_id),
|
||||||
|
Some(&active_action_id),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requested_command_identity_rejects_duplicate_id_from_another_conversation() {
|
||||||
|
let active_conversation_id = AIConversationId::new();
|
||||||
|
let other_conversation_id = AIConversationId::new();
|
||||||
|
let duplicate_action_id = AIAgentActionId::from("duplicate-action".to_owned());
|
||||||
|
|
||||||
|
assert!(matches_requested_command_identity(
|
||||||
|
active_conversation_id,
|
||||||
|
&duplicate_action_id,
|
||||||
|
Some(active_conversation_id),
|
||||||
|
Some(&duplicate_action_id),
|
||||||
|
));
|
||||||
|
assert!(!matches_requested_command_identity(
|
||||||
|
other_conversation_id,
|
||||||
|
&duplicate_action_id,
|
||||||
|
Some(active_conversation_id),
|
||||||
|
Some(&duplicate_action_id),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use galaxy_agent_core::RuntimeActivity;
|
||||||
use galaxy_terminal::model::escape_sequences;
|
use galaxy_terminal::model::escape_sequences;
|
||||||
|
|
||||||
use super::{get_action_icon, get_action_loading_text, should_retain_task_output_message};
|
use super::{get_action_icon, get_action_loading_text, should_retain_task_output_message};
|
||||||
@@ -58,4 +59,13 @@ fn transcript_retains_prior_text_but_only_latest_tool_activity() {
|
|||||||
});
|
});
|
||||||
assert!(!should_retain_task_output_message(&poll, false));
|
assert!(!should_retain_task_output_message(&poll, false));
|
||||||
assert!(should_retain_task_output_message(&poll, true));
|
assert!(should_retain_task_output_message(&poll, true));
|
||||||
|
|
||||||
|
let runtime_activity = AIAgentOutputMessageType::RuntimeActivity(RuntimeActivity {
|
||||||
|
id: "acp-tool".to_owned(),
|
||||||
|
title: "Inspect repository".to_owned(),
|
||||||
|
status: None,
|
||||||
|
output: None,
|
||||||
|
});
|
||||||
|
assert!(!should_retain_task_output_message(&runtime_activity, false));
|
||||||
|
assert!(should_retain_task_output_message(&runtime_activity, true));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,11 @@ impl<T: ?Sized + AIBlockModel> AIBlockModelHelper for T {
|
|||||||
let output = output.get();
|
let output = output.get();
|
||||||
output.messages.iter().find_map(|message| {
|
output.messages.iter().find_map(|message| {
|
||||||
if let AIAgentOutputMessageType::Action(action) = &message.message {
|
if let AIAgentOutputMessageType::Action(action) = &message.message {
|
||||||
if let Some(status) = action_model.as_ref(app).get_action_status(&action.id) {
|
if let Some(status) = self.conversation_id(app).and_then(|conversation_id| {
|
||||||
|
action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(conversation_id, &action.id)
|
||||||
|
}) {
|
||||||
return status.is_blocked().then_some(action.clone());
|
return status.is_blocked().then_some(action.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,10 +328,27 @@ impl BlocklistAIStatusBar {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.subscribe_to_model(&action_model, |_, _, event, ctx| match event {
|
ctx.subscribe_to_model(&action_model, |me, _, event, ctx| match event {
|
||||||
BlocklistAIActionEvent::ExecutingAction(..)
|
BlocklistAIActionEvent::ExecutingAction {
|
||||||
| BlocklistAIActionEvent::FinishedAction { .. } => ctx.notify(),
|
conversation_id, ..
|
||||||
_ => (),
|
}
|
||||||
|
| BlocklistAIActionEvent::FinishedAction {
|
||||||
|
conversation_id, ..
|
||||||
|
} if me
|
||||||
|
.active_exchange_model
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|model| model.conversation_id(ctx) == Some(*conversation_id)) =>
|
||||||
|
{
|
||||||
|
ctx.notify();
|
||||||
|
}
|
||||||
|
BlocklistAIActionEvent::QueuedAction { .. }
|
||||||
|
| BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. }
|
||||||
|
| BlocklistAIActionEvent::ExecutingAction { .. }
|
||||||
|
| BlocklistAIActionEvent::FinishedAction { .. }
|
||||||
|
| BlocklistAIActionEvent::ToolLifecycle { .. }
|
||||||
|
| BlocklistAIActionEvent::InitProject(_)
|
||||||
|
| BlocklistAIActionEvent::ToggleCodeReview(_)
|
||||||
|
| BlocklistAIActionEvent::InsertCodeReviewComments { .. } => {}
|
||||||
});
|
});
|
||||||
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| match event {
|
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| match event {
|
||||||
ModelEvent::AfterBlockStarted { block_id, .. } => {
|
ModelEvent::AfterBlockStarted { block_id, .. } => {
|
||||||
|
|||||||
@@ -1079,6 +1079,7 @@ impl View for AIBlock {
|
|||||||
|
|
||||||
contents.add_child(output::render(
|
contents.add_child(output::render(
|
||||||
output::Props {
|
output::Props {
|
||||||
|
conversation_id: self.client_ids.conversation_id,
|
||||||
model: self.model.as_ref(),
|
model: self.model.as_ref(),
|
||||||
state_handles: &self.state_handles,
|
state_handles: &self.state_handles,
|
||||||
action_buttons: &self.action_buttons,
|
action_buttons: &self.action_buttons,
|
||||||
@@ -1375,6 +1376,7 @@ impl AIAgentInput {
|
|||||||
app,
|
app,
|
||||||
)),
|
)),
|
||||||
AIAgentInput::UserQuery { .. }
|
AIAgentInput::UserQuery { .. }
|
||||||
|
| AIAgentInput::CommandCompletionAssessment { .. }
|
||||||
| AIAgentInput::AutoCodeDiffQuery { .. }
|
| AIAgentInput::AutoCodeDiffQuery { .. }
|
||||||
| AIAgentInput::ResumeConversation { .. }
|
| AIAgentInput::ResumeConversation { .. }
|
||||||
| AIAgentInput::InitProjectRules { .. }
|
| AIAgentInput::InitProjectRules { .. }
|
||||||
|
|||||||
@@ -3687,6 +3687,7 @@ pub(super) fn query_prefix_highlight_len(
|
|||||||
match input {
|
match input {
|
||||||
AIAgentInput::InvokeSkill { skill, .. } => Some(1 + skill.name.len()),
|
AIAgentInput::InvokeSkill { skill, .. } => Some(1 + skill.name.len()),
|
||||||
AIAgentInput::UserQuery { .. }
|
AIAgentInput::UserQuery { .. }
|
||||||
|
| AIAgentInput::CommandCompletionAssessment { .. }
|
||||||
| AIAgentInput::AutoCodeDiffQuery { .. }
|
| AIAgentInput::AutoCodeDiffQuery { .. }
|
||||||
| AIAgentInput::ResumeConversation { .. }
|
| AIAgentInput::ResumeConversation { .. }
|
||||||
| AIAgentInput::InitProjectRules { .. }
|
| AIAgentInput::InitProjectRules { .. }
|
||||||
|
|||||||
@@ -420,7 +420,10 @@ pub(super) fn render_send_message(
|
|||||||
) -> Box<dyn Element> {
|
) -> Box<dyn Element> {
|
||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let theme = appearance.theme();
|
let theme = appearance.theme();
|
||||||
let status = props.action_model.as_ref(app).get_action_status(action_id);
|
let status = props
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, action_id);
|
||||||
let orchestrator_agent_id = props
|
let orchestrator_agent_id = props
|
||||||
.model
|
.model
|
||||||
.conversation(app)
|
.conversation(app)
|
||||||
@@ -564,7 +567,10 @@ pub(super) fn render_start_agent(
|
|||||||
) -> Box<dyn Element> {
|
) -> Box<dyn Element> {
|
||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let theme = appearance.theme();
|
let theme = appearance.theme();
|
||||||
let status = props.action_model.as_ref(app).get_action_status(action_id);
|
let status = props
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, action_id);
|
||||||
|
|
||||||
if let Some(AIActionStatus::Finished(result)) = &status {
|
if let Some(AIActionStatus::Finished(result)) = &status {
|
||||||
let AIAgentActionResultType::StartAgent(result) = &result.result else {
|
let AIAgentActionResultType::StartAgent(result) = &result.result else {
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ use ai::agent::action::{
|
|||||||
};
|
};
|
||||||
use ai::agent::file_locations::group_file_contexts_for_display;
|
use ai::agent::file_locations::group_file_contexts_for_display;
|
||||||
use ai::skills::{ParsedSkill, SkillReference};
|
use ai::skills::{ParsedSkill, SkillReference};
|
||||||
|
use galaxy_agent_core::{RuntimeActivity, RuntimeActivityStatus};
|
||||||
use galaxy_core::channel::ChannelState;
|
use galaxy_core::channel::ChannelState;
|
||||||
use galaxy_core::ui::theme::color::internal_colors;
|
use galaxy_core::ui::theme::color::internal_colors;
|
||||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||||
use galaxyui::elements::new_scrollable::SingleAxisConfig;
|
use galaxyui::elements::new_scrollable::SingleAxisConfig;
|
||||||
use galaxyui::elements::{
|
use galaxyui::elements::{
|
||||||
Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
Align, Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
|
||||||
CrossAxisAlignment, Empty, Expanded, Fill, Flex, FormattedTextElement, Hoverable,
|
CrossAxisAlignment, Empty, Expanded, Fill, Flex, FormattedTextElement, Hoverable,
|
||||||
MainAxisAlignment, MainAxisSize, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement,
|
MainAxisAlignment, MainAxisSize, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement,
|
||||||
ParentOffsetBounds, Radius, Shrinkable, Stack, Text, Wrap,
|
ParentOffsetBounds, Radius, Shrinkable, Stack, Text, Wrap,
|
||||||
@@ -55,6 +56,7 @@ use super::{
|
|||||||
};
|
};
|
||||||
use crate::ai::agent::api::ServerConversationToken;
|
use crate::ai::agent::api::ServerConversationToken;
|
||||||
use crate::ai::agent::comment::ReviewComment;
|
use crate::ai::agent::comment::ReviewComment;
|
||||||
|
use crate::ai::agent::conversation::AIConversationId;
|
||||||
use crate::ai::agent::icons::{self, gray_stop_icon, yellow_stop_icon};
|
use crate::ai::agent::icons::{self, gray_stop_icon, yellow_stop_icon};
|
||||||
use crate::ai::agent::task::TaskId;
|
use crate::ai::agent::task::TaskId;
|
||||||
use crate::ai::agent::{
|
use crate::ai::agent::{
|
||||||
@@ -84,18 +86,22 @@ use crate::ai::blocklist::inline_action::ask_user_question_view::AskUserQuestion
|
|||||||
use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView;
|
use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView;
|
||||||
use crate::ai::blocklist::inline_action::create_or_edit_document::CreateOrEditDocumentAction;
|
use crate::ai::blocklist::inline_action::create_or_edit_document::CreateOrEditDocumentAction;
|
||||||
use crate::ai::blocklist::inline_action::inline_action_header::{
|
use crate::ai::blocklist::inline_action::inline_action_header::{
|
||||||
HeaderConfig, InteractionMode, INLINE_ACTION_HEADER_VERTICAL_PADDING,
|
ExpandedConfig, HeaderConfig, InteractionMode, INLINE_ACTION_HEADER_VERTICAL_PADDING,
|
||||||
INLINE_ACTION_HORIZONTAL_PADDING,
|
INLINE_ACTION_HORIZONTAL_PADDING,
|
||||||
};
|
};
|
||||||
use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size};
|
use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size};
|
||||||
use crate::ai::blocklist::inline_action::requested_action::{
|
use crate::ai::blocklist::inline_action::requested_action::{
|
||||||
render_requested_action_body_text, render_requested_action_row_for_text, RenderableAction,
|
render_requested_action_body_text, render_requested_action_row_for_text, RenderableAction,
|
||||||
};
|
};
|
||||||
use crate::ai::blocklist::inline_action::requested_command::RequestedCommand;
|
use crate::ai::blocklist::inline_action::requested_command::{
|
||||||
|
format_command_text, RequestedCommand, REQUESTED_COMMAND_BODY_VERTICAL_PADDING,
|
||||||
|
VIEWING_COMMAND_DETAIL_MESSAGE,
|
||||||
|
};
|
||||||
use crate::ai::blocklist::inline_action::run_agents_card_view::RunAgentsCardView;
|
use crate::ai::blocklist::inline_action::run_agents_card_view::RunAgentsCardView;
|
||||||
use crate::ai::blocklist::inline_action::search_codebase::SearchCodebaseView;
|
use crate::ai::blocklist::inline_action::search_codebase::SearchCodebaseView;
|
||||||
use crate::ai::blocklist::inline_action::suggested_unit_tests::SuggestedUnitTestsView;
|
use crate::ai::blocklist::inline_action::suggested_unit_tests::SuggestedUnitTestsView;
|
||||||
use crate::ai::blocklist::inline_action::summarization::SummarizationView;
|
use crate::ai::blocklist::inline_action::summarization::SummarizationView;
|
||||||
|
use crate::ai::blocklist::inline_action::tool_pane::render_tool_pane_shell;
|
||||||
use crate::ai::blocklist::inline_action::web_fetch::WebFetchView;
|
use crate::ai::blocklist::inline_action::web_fetch::WebFetchView;
|
||||||
use crate::ai::blocklist::inline_action::web_search::WebSearchView;
|
use crate::ai::blocklist::inline_action::web_search::WebSearchView;
|
||||||
use crate::ai::blocklist::keyboard_navigable_buttons::KeyboardNavigableButtons;
|
use crate::ai::blocklist::keyboard_navigable_buttons::KeyboardNavigableButtons;
|
||||||
@@ -131,9 +137,14 @@ use crate::{AIAgentTodoList, FeatureFlag};
|
|||||||
|
|
||||||
const BLOCKED_ACTION_MESSAGE_FOR_UPLOADING_ARTIFACT: &str = "Grant access to upload this artifact?";
|
const BLOCKED_ACTION_MESSAGE_FOR_UPLOADING_ARTIFACT: &str = "Grant access to upload this artifact?";
|
||||||
|
|
||||||
|
fn should_render_requested_edit(action_status: Option<&AIActionStatus>) -> bool {
|
||||||
|
!action_status.is_some_and(AIActionStatus::is_preprocessing)
|
||||||
|
}
|
||||||
|
|
||||||
/// Data required to render the AI block output component.
|
/// Data required to render the AI block output component.
|
||||||
#[derive(Copy, Clone)]
|
#[derive(Copy, Clone)]
|
||||||
pub(crate) struct Props<'a> {
|
pub(crate) struct Props<'a> {
|
||||||
|
pub(crate) conversation_id: AIConversationId,
|
||||||
pub(crate) model: &'a dyn AIBlockModel<View = AIBlock>,
|
pub(crate) model: &'a dyn AIBlockModel<View = AIBlock>,
|
||||||
pub(super) state_handles: &'a AIBlockStateHandles,
|
pub(super) state_handles: &'a AIBlockStateHandles,
|
||||||
pub(super) action_buttons: &'a HashMap<AIAgentActionId, ActionButtons>,
|
pub(super) action_buttons: &'a HashMap<AIAgentActionId, ActionButtons>,
|
||||||
@@ -400,6 +411,21 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
|||||||
} if !are_all_text_sections_empty(sections) => {
|
} if !are_all_text_sections_empty(sections) => {
|
||||||
text_section_index += sections.len();
|
text_section_index += sections.len();
|
||||||
}
|
}
|
||||||
|
AIAgentOutputMessageType::RuntimeActivity(activity) => {
|
||||||
|
if !matches!(
|
||||||
|
activity.status,
|
||||||
|
Some(RuntimeActivityStatus::Completed)
|
||||||
|
| Some(RuntimeActivityStatus::Failed)
|
||||||
|
) {
|
||||||
|
should_render_footer = false;
|
||||||
|
should_render_suggestions = false;
|
||||||
|
}
|
||||||
|
if let Some(rendered_activity) =
|
||||||
|
render_runtime_activity(output_message, activity, props, app)
|
||||||
|
{
|
||||||
|
output_items.add_child(rendered_activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
AIAgentOutputMessageType::Action(AIAgentAction {
|
AIAgentOutputMessageType::Action(AIAgentAction {
|
||||||
action: AIAgentActionType::RequestCommandOutput { .. },
|
action: AIAgentActionType::RequestCommandOutput { .. },
|
||||||
id,
|
id,
|
||||||
@@ -412,7 +438,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
|||||||
let is_action_done = props
|
let is_action_done = props
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.get_action_status(id)
|
.get_action_status(props.conversation_id, id)
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|status| status.is_done());
|
.is_some_and(|status| status.is_done());
|
||||||
if !is_action_done {
|
if !is_action_done {
|
||||||
@@ -452,7 +478,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
|||||||
let agent_action_results = props
|
let agent_action_results = props
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.get_action_result(id)
|
.get_action_result(props.conversation_id, id)
|
||||||
.map(|action_result| action_result.as_ref());
|
.map(|action_result| action_result.as_ref());
|
||||||
|
|
||||||
// checks if the read file action result is completed and successful.
|
// checks if the read file action result is completed and successful.
|
||||||
@@ -541,13 +567,12 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
|||||||
id,
|
id,
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
let action_status =
|
let action_status = props
|
||||||
props.action_model.as_ref(app).get_action_status(id);
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, id);
|
||||||
|
|
||||||
let is_preprocessing = action_status
|
if should_render_requested_edit(action_status.as_ref()) {
|
||||||
.clone()
|
|
||||||
.is_some_and(|status| status.is_preprocessing());
|
|
||||||
if !is_preprocessing && !status.is_streaming() {
|
|
||||||
if let Some(requested_edit) = props.requested_edits.get(id) {
|
if let Some(requested_edit) = props.requested_edits.get(id) {
|
||||||
// Don't render the requested edit if the diffs are empty for passive code diffs.
|
// Don't render the requested edit if the diffs are empty for passive code diffs.
|
||||||
if request_type.is_passive_code_diff()
|
if request_type.is_passive_code_diff()
|
||||||
@@ -635,7 +660,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
|||||||
let is_action_done = props
|
let is_action_done = props
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.get_action_status(id)
|
.get_action_status(props.conversation_id, id)
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|status| status.is_done());
|
.is_some_and(|status| status.is_done());
|
||||||
if !is_action_done {
|
if !is_action_done {
|
||||||
@@ -1262,6 +1287,106 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
|||||||
output_items.finish()
|
output_items.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_runtime_activity(
|
||||||
|
output_message: &AIAgentOutputMessage,
|
||||||
|
activity: &RuntimeActivity,
|
||||||
|
props: Props,
|
||||||
|
app: &AppContext,
|
||||||
|
) -> Option<Box<dyn Element>> {
|
||||||
|
let state = props.collapsible_block_states.get(&output_message.id)?;
|
||||||
|
let appearance = Appearance::as_ref(app);
|
||||||
|
let theme = appearance.theme();
|
||||||
|
let output = activity
|
||||||
|
.output
|
||||||
|
.as_deref()
|
||||||
|
.filter(|output| !output.is_empty());
|
||||||
|
let is_expanded = matches!(
|
||||||
|
state.expansion_state,
|
||||||
|
CollapsibleExpansionState::Expanded { .. }
|
||||||
|
);
|
||||||
|
let icon = match activity.status.as_ref() {
|
||||||
|
Some(RuntimeActivityStatus::Pending) => icons::pending_icon(appearance),
|
||||||
|
Some(RuntimeActivityStatus::InProgress) => icons::yellow_running_icon(appearance),
|
||||||
|
Some(RuntimeActivityStatus::Completed) => inline_action_icons::green_check_icon(appearance),
|
||||||
|
Some(RuntimeActivityStatus::Failed) => inline_action_icons::red_x_icon(appearance),
|
||||||
|
Some(RuntimeActivityStatus::Other(_)) | None => icons::gray_circle_icon(appearance),
|
||||||
|
};
|
||||||
|
let title = if is_expanded {
|
||||||
|
VIEWING_COMMAND_DETAIL_MESSAGE.to_owned()
|
||||||
|
} else {
|
||||||
|
format_command_text(&activity.title)
|
||||||
|
};
|
||||||
|
let mut header = HeaderConfig::new(title, app)
|
||||||
|
.with_selectable_text()
|
||||||
|
.with_icon(icon)
|
||||||
|
.with_corner_radius_override(if is_expanded && output.is_some() {
|
||||||
|
CornerRadius::with_top(Radius::Pixels(8.))
|
||||||
|
} else {
|
||||||
|
CornerRadius::with_all(Radius::Pixels(8.))
|
||||||
|
});
|
||||||
|
if !is_expanded {
|
||||||
|
header = header.with_font_family(appearance.monospace_font_family());
|
||||||
|
}
|
||||||
|
if output.is_some() {
|
||||||
|
let message_id = output_message.id.clone();
|
||||||
|
let command = activity.title.clone();
|
||||||
|
let expansion =
|
||||||
|
ExpandedConfig::new(is_expanded, state.expansion_toggle_mouse_state.clone())
|
||||||
|
.with_toggle_callback(move |ctx| {
|
||||||
|
ctx.dispatch_typed_action(AIBlockAction::ToggleCollapsibleBlockExpanded(
|
||||||
|
message_id.clone(),
|
||||||
|
));
|
||||||
|
})
|
||||||
|
.with_right_click_callback(move |ctx| {
|
||||||
|
ctx.dispatch_typed_action(AIBlockAction::StoreRightClickedCommand {
|
||||||
|
command: command.clone(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
header = header.with_interaction_mode(InteractionMode::ManuallyExpandable(expansion));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut content = Flex::column()
|
||||||
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||||
|
.with_child(Clipped::new(header.render(app)).finish());
|
||||||
|
|
||||||
|
if let Some(output) = output {
|
||||||
|
let body = render_requested_action_body_text(
|
||||||
|
output.into(),
|
||||||
|
appearance.monospace_font_family(),
|
||||||
|
app,
|
||||||
|
)
|
||||||
|
.finish();
|
||||||
|
let is_streaming = matches!(
|
||||||
|
activity.status,
|
||||||
|
Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress)
|
||||||
|
);
|
||||||
|
if let Some(scrollable) = render_scrollable_collapsible_content(
|
||||||
|
&output_message.id,
|
||||||
|
state,
|
||||||
|
body,
|
||||||
|
is_streaming,
|
||||||
|
320.,
|
||||||
|
) {
|
||||||
|
content.add_child(
|
||||||
|
Container::new(scrollable)
|
||||||
|
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||||
|
.with_vertical_padding(REQUESTED_COMMAND_BODY_VERTICAL_PADDING)
|
||||||
|
.with_background(theme.background())
|
||||||
|
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(render_tool_pane_shell(
|
||||||
|
content.finish(),
|
||||||
|
false,
|
||||||
|
is_expanded,
|
||||||
|
false,
|
||||||
|
app,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn should_render_stopped_output(props: Props, app: &AppContext) -> bool {
|
fn should_render_stopped_output(props: Props, app: &AppContext) -> bool {
|
||||||
if FeatureFlag::AgentView.is_enabled() {
|
if FeatureFlag::AgentView.is_enabled() {
|
||||||
return false;
|
return false;
|
||||||
@@ -1358,7 +1483,10 @@ fn render_search_codebase(
|
|||||||
id: &AIAgentActionId,
|
id: &AIAgentActionId,
|
||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
) -> Option<Box<dyn Element>> {
|
) -> Option<Box<dyn Element>> {
|
||||||
let status = props.action_model.as_ref(app).get_action_status(id);
|
let status = props
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, id);
|
||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let theme = appearance.theme();
|
let theme = appearance.theme();
|
||||||
|
|
||||||
@@ -1859,7 +1987,10 @@ fn render_read_files(
|
|||||||
parsed_skill: Option<&ai::skills::ParsedSkill>,
|
parsed_skill: Option<&ai::skills::ParsedSkill>,
|
||||||
action_index: usize,
|
action_index: usize,
|
||||||
) -> Box<dyn Element> {
|
) -> Box<dyn Element> {
|
||||||
let status = props.action_model.as_ref(app).get_action_status(id);
|
let status = props
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, id);
|
||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let formatted_files =
|
let formatted_files =
|
||||||
render_read_files_text(props.into(), file_names, app, appearance, action_index);
|
render_read_files_text(props.into(), file_names, app, appearance, action_index);
|
||||||
@@ -1976,7 +2107,10 @@ fn maybe_render_edit_document(
|
|||||||
id: &AIAgentActionId,
|
id: &AIAgentActionId,
|
||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
) -> Option<Box<dyn Element>> {
|
) -> Option<Box<dyn Element>> {
|
||||||
let status = props.action_model.as_ref(app).get_action_status(id);
|
let status = props
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, id);
|
||||||
|
|
||||||
// Document operations are always auto-executed for now
|
// Document operations are always auto-executed for now
|
||||||
if status.as_ref().is_some_and(|status| status.is_blocked()) {
|
if status.as_ref().is_some_and(|status| status.is_blocked()) {
|
||||||
@@ -1986,7 +2120,7 @@ fn maybe_render_edit_document(
|
|||||||
let agent_action_results = props
|
let agent_action_results = props
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.get_action_result(id)
|
.get_action_result(props.conversation_id, id)
|
||||||
.map(|action_result| action_result.as_ref());
|
.map(|action_result| action_result.as_ref());
|
||||||
|
|
||||||
let Some(AIAgentActionResult {
|
let Some(AIAgentActionResult {
|
||||||
@@ -2013,7 +2147,10 @@ fn maybe_render_create_document(
|
|||||||
id: &AIAgentActionId,
|
id: &AIAgentActionId,
|
||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
) -> Option<Box<dyn Element>> {
|
) -> Option<Box<dyn Element>> {
|
||||||
let status = props.action_model.as_ref(app).get_action_status(id);
|
let status = props
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, id);
|
||||||
|
|
||||||
// Document operations are always auto-executed for now
|
// Document operations are always auto-executed for now
|
||||||
if status.as_ref().is_some_and(|status| status.is_blocked()) {
|
if status.as_ref().is_some_and(|status| status.is_blocked()) {
|
||||||
@@ -2023,7 +2160,7 @@ fn maybe_render_create_document(
|
|||||||
let agent_action_results = props
|
let agent_action_results = props
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.get_action_result(id)
|
.get_action_result(props.conversation_id, id)
|
||||||
.map(|action_result| action_result.as_ref());
|
.map(|action_result| action_result.as_ref());
|
||||||
|
|
||||||
let Some(AIAgentActionResult {
|
let Some(AIAgentActionResult {
|
||||||
@@ -2326,7 +2463,7 @@ fn render_suggest_new_conversation(
|
|||||||
let status = props
|
let status = props
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.get_action_status(action_id)
|
.get_action_status(props.conversation_id, action_id)
|
||||||
.unwrap_or(AIActionStatus::Finished(Arc::new(AIAgentActionResult {
|
.unwrap_or(AIActionStatus::Finished(Arc::new(AIAgentActionResult {
|
||||||
result: AIAgentActionResultType::SuggestNewConversation(
|
result: AIAgentActionResultType::SuggestNewConversation(
|
||||||
SuggestNewConversationResult::Cancelled,
|
SuggestNewConversationResult::Cancelled,
|
||||||
@@ -2434,7 +2571,10 @@ fn create_formatted_text_for_grep(
|
|||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let theme = appearance.theme();
|
let theme = appearance.theme();
|
||||||
|
|
||||||
let action_status = props.action_model.as_ref(app).get_action_status(id);
|
let action_status = props
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, id);
|
||||||
let is_cancelled = action_status
|
let is_cancelled = action_status
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|status| status.is_cancelled());
|
.is_some_and(|status| status.is_cancelled());
|
||||||
@@ -2538,7 +2678,10 @@ fn create_formatted_text_for_file_glob(
|
|||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let theme = appearance.theme();
|
let theme = appearance.theme();
|
||||||
|
|
||||||
let action_status = props.action_model.as_ref(app).get_action_status(id);
|
let action_status = props
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, id);
|
||||||
let is_cancelled = action_status
|
let is_cancelled = action_status
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|status| status.is_cancelled());
|
.is_some_and(|status| status.is_cancelled());
|
||||||
@@ -2639,7 +2782,10 @@ fn render_file_retrieval_tool(
|
|||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
) -> Box<dyn Element> {
|
) -> Box<dyn Element> {
|
||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let status = props.action_model.as_ref(app).get_action_status(action_id);
|
let status = props
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, action_id);
|
||||||
|
|
||||||
let mut config = RenderableAction::new_with_formatted_text(tool_formatted_text, app);
|
let mut config = RenderableAction::new_with_formatted_text(tool_formatted_text, app);
|
||||||
|
|
||||||
@@ -2756,7 +2902,10 @@ fn render_read_mcp_resource(
|
|||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
) -> Box<dyn Element> {
|
) -> Box<dyn Element> {
|
||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let status = props.action_model.as_ref(app).get_action_status(action_id);
|
let status = props
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, action_id);
|
||||||
|
|
||||||
let mut renderable_action = RenderableAction::new(name, app);
|
let mut renderable_action = RenderableAction::new(name, app);
|
||||||
|
|
||||||
@@ -2833,11 +2982,14 @@ fn render_upload_artifact(
|
|||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
) -> Box<dyn Element> {
|
) -> Box<dyn Element> {
|
||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let status = props.action_model.as_ref(app).get_action_status(action_id);
|
let status = props
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, action_id);
|
||||||
let result = props
|
let result = props
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.get_action_result(action_id)
|
.get_action_result(props.conversation_id, action_id)
|
||||||
.and_then(|result| match &result.result {
|
.and_then(|result| match &result.result {
|
||||||
AIAgentActionResultType::UploadArtifact(upload_result) => Some(upload_result),
|
AIAgentActionResultType::UploadArtifact(upload_result) => Some(upload_result),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -2896,7 +3048,7 @@ fn render_use_computer(
|
|||||||
let has_screenshot = props
|
let has_screenshot = props
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.get_action_result(action_id)
|
.get_action_result(props.conversation_id, action_id)
|
||||||
.is_some_and(|result| {
|
.is_some_and(|result| {
|
||||||
matches!(
|
matches!(
|
||||||
&result.result,
|
&result.result,
|
||||||
@@ -2942,7 +3094,10 @@ fn render_request_computer_use(
|
|||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
) -> Box<dyn Element> {
|
) -> Box<dyn Element> {
|
||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let status = props.action_model.as_ref(app).get_action_status(action_id);
|
let status = props
|
||||||
|
.action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(props.conversation_id, action_id);
|
||||||
|
|
||||||
let mut renderable_action = RenderableAction::new(&request.task_summary, app);
|
let mut renderable_action = RenderableAction::new(&request.task_summary, app);
|
||||||
|
|
||||||
@@ -3523,7 +3678,13 @@ pub fn action_icon<V: View>(
|
|||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
) -> galaxyui::elements::Icon {
|
) -> galaxyui::elements::Icon {
|
||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let status = action_model.as_ref(app).get_action_status(action_id);
|
let status = ai_block_model
|
||||||
|
.conversation_id(app)
|
||||||
|
.and_then(|conversation_id| {
|
||||||
|
action_model
|
||||||
|
.as_ref(app)
|
||||||
|
.get_action_status(conversation_id, action_id)
|
||||||
|
});
|
||||||
match status {
|
match status {
|
||||||
Some(status) => match status {
|
Some(status) => match status {
|
||||||
AIActionStatus::Preprocessing => icons::gray_circle_icon(appearance),
|
AIActionStatus::Preprocessing => icons::gray_circle_icon(appearance),
|
||||||
|
|||||||
@@ -11,12 +11,23 @@ use watcher::HomeDirectoryWatcher;
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
format_upload_artifact_text, parsed_skill_for_common_locations, read_skill_display_text,
|
format_upload_artifact_text, parsed_skill_for_common_locations, read_skill_display_text,
|
||||||
|
should_render_requested_edit,
|
||||||
};
|
};
|
||||||
use crate::ai::agent::UploadArtifactResult;
|
use crate::ai::agent::UploadArtifactResult;
|
||||||
|
use crate::ai::blocklist::action_model::AIActionStatus;
|
||||||
use crate::ai::skills::SkillManager;
|
use crate::ai::skills::SkillManager;
|
||||||
use crate::settings::AISettings;
|
use crate::settings::AISettings;
|
||||||
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
|
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requested_edits_render_as_soon_as_preprocessing_finishes() {
|
||||||
|
assert!(!should_render_requested_edit(Some(
|
||||||
|
&AIActionStatus::Preprocessing
|
||||||
|
)));
|
||||||
|
assert!(should_render_requested_edit(Some(&AIActionStatus::Blocked)));
|
||||||
|
assert!(should_render_requested_edit(None));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_upload_artifact_text_includes_request_details() {
|
fn format_upload_artifact_text_includes_request_details() {
|
||||||
let request = UploadArtifactRequest {
|
let request = UploadArtifactRequest {
|
||||||
|
|||||||
@@ -103,6 +103,54 @@ fn collapsed_initializer_starts_collapsed() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_runtime_activity_stays_collapsed_until_opened() {
|
||||||
|
let mut state = CollapsibleElementState::collapsed();
|
||||||
|
|
||||||
|
state.sync_runtime_activity(false, true, true);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
state.expansion_state,
|
||||||
|
CollapsibleExpansionState::Collapsed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn streaming_runtime_activity_expands_when_output_arrives() {
|
||||||
|
let mut state = CollapsibleElementState::collapsed();
|
||||||
|
|
||||||
|
state.sync_runtime_activity(true, false, true);
|
||||||
|
assert!(matches!(
|
||||||
|
state.expansion_state,
|
||||||
|
CollapsibleExpansionState::Expanded {
|
||||||
|
is_finished: false,
|
||||||
|
scroll_pinned_to_bottom: true
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
state.sync_runtime_activity(false, true, true);
|
||||||
|
assert!(matches!(
|
||||||
|
state.expansion_state,
|
||||||
|
CollapsibleExpansionState::Expanded {
|
||||||
|
is_finished: true,
|
||||||
|
scroll_pinned_to_bottom: false
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manually_collapsed_streaming_runtime_activity_stays_collapsed() {
|
||||||
|
let mut state = CollapsibleElementState::default();
|
||||||
|
state.toggle_expansion();
|
||||||
|
|
||||||
|
state.sync_runtime_activity(true, false, true);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
state.expansion_state,
|
||||||
|
CollapsibleExpansionState::Collapsed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn orchestration_show_and_collapse_collapses_after_finish() {
|
fn orchestration_show_and_collapse_collapses_after_finish() {
|
||||||
let mut state = default_collapsible_state_for_orchestration_message(
|
let mut state = default_collapsible_state_for_orchestration_message(
|
||||||
|
|||||||
@@ -188,13 +188,37 @@ impl BlocklistAIContextModel {
|
|||||||
);
|
);
|
||||||
|
|
||||||
ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| {
|
ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| {
|
||||||
if let LLMPreferencesEvent::UpdatedActiveAgentModeLLM = event {
|
if matches!(
|
||||||
|
event,
|
||||||
|
LLMPreferencesEvent::UpdatedActiveAgentModeLLM
|
||||||
|
| LLMPreferencesEvent::UpdatedAvailableLLMs
|
||||||
|
) {
|
||||||
let llm_prefs = LLMPreferences::as_ref(ctx);
|
let llm_prefs = LLMPreferences::as_ref(ctx);
|
||||||
let vision_supported =
|
let vision_supported =
|
||||||
llm_prefs.vision_supported(ctx, Some(me.terminal_surface_id));
|
llm_prefs.vision_supported(ctx, Some(me.terminal_surface_id));
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
let desired_backend =
|
||||||
|
llm_prefs.agent_backend_for_active_model(Some(me.terminal_surface_id), ctx);
|
||||||
if !vision_supported {
|
if !vision_supported {
|
||||||
me.clear_pending_images(ctx);
|
me.clear_pending_images(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ACP and provider histories have different owners. When the
|
||||||
|
// selected model crosses that boundary, make the next prompt a
|
||||||
|
// fresh conversation instead of silently sending it through
|
||||||
|
// the backend that owned the existing conversation.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
{
|
||||||
|
let selected_backend = me
|
||||||
|
.selected_conversation(ctx)
|
||||||
|
.map(|conversation| conversation.agent_backend().clone());
|
||||||
|
if selected_backend.is_some_and(|backend| backend != desired_backend) {
|
||||||
|
me.set_pending_query_state_for_new_conversation(
|
||||||
|
AgentViewEntryOrigin::ConversationSelector,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+4618
-441
File diff suppressed because it is too large
Load Diff
@@ -52,11 +52,15 @@ impl PendingResponseStreams {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to inject a plain-text follow-up into the active ACP turn.
|
pub fn has_stream(&self, stream_id: &ResponseStreamId) -> bool {
|
||||||
|
self.streams.contains_key(stream_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to inject a plain-text follow-up into an active steerable runtime.
|
||||||
///
|
///
|
||||||
/// Returning `None` leaves the caller free to use the normal
|
/// Returning `None` leaves the caller free to use the normal
|
||||||
/// cancel-and-queue path without dropping the user's message.
|
/// cancel-and-queue path without dropping the user's message.
|
||||||
pub fn try_steer_acp_stream_for_conversation(
|
pub fn try_steer_runtime_for_conversation(
|
||||||
&self,
|
&self,
|
||||||
conversation_id: AIConversationId,
|
conversation_id: AIConversationId,
|
||||||
display_text: String,
|
display_text: String,
|
||||||
@@ -71,7 +75,7 @@ impl PendingResponseStreams {
|
|||||||
let model_id = stream.as_ref(app).llm_id().clone();
|
let model_id = stream.as_ref(app).llm_id().clone();
|
||||||
stream
|
stream
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.try_steer_acp(display_text)
|
.try_steer_runtime(display_text)
|
||||||
.then(|| (stream_id.clone(), model_id))
|
.then(|| (stream_id.clone(), model_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +91,14 @@ impl PendingResponseStreams {
|
|||||||
self.streams.insert(stream_id, stream);
|
self.streams.insert(stream_id, stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn register_additional_stream(
|
||||||
|
&mut self,
|
||||||
|
stream_id: ResponseStreamId,
|
||||||
|
stream: ModelHandle<ResponseStream>,
|
||||||
|
) {
|
||||||
|
self.streams.insert(stream_id, stream);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cleanup_stream(&mut self, stream_id: &ResponseStreamId) {
|
pub fn cleanup_stream(&mut self, stream_id: &ResponseStreamId) {
|
||||||
self.streams.remove(stream_id);
|
self.streams.remove(stream_id);
|
||||||
}
|
}
|
||||||
@@ -136,11 +148,13 @@ impl PendingResponseStreams {
|
|||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
for response_stream in streams_to_cancel.into_iter() {
|
for response_stream in streams_to_cancel.into_iter() {
|
||||||
log::info!(
|
crate::ai::tool_diagnostics::tool_debug!(
|
||||||
"Canceling active stream for conversation_id={conversation_id:?}, \
|
"Canceling active stream for conversation_id={conversation_id:?}, \
|
||||||
reason={reason}, backtrace=\n{}",
|
reason={reason}"
|
||||||
std::backtrace::Backtrace::force_capture()
|
|
||||||
);
|
);
|
||||||
|
if let Some(backtrace) = crate::ai::tool_diagnostics::capture_backtrace() {
|
||||||
|
log::debug!("Active stream cancellation backtrace:\n{backtrace}");
|
||||||
|
}
|
||||||
response_stream.update(ctx, |stream, ctx| {
|
response_stream.update(ctx, |stream, ctx| {
|
||||||
stream.cancel(reason, conversation_id, ctx)
|
stream.cancel(reason, conversation_id, ctx)
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,86 +1,22 @@
|
|||||||
use super::{is_interactive_remote_command, recovery_action, RecoveryAction};
|
use warp_multi_agent_api::response_event::stream_finished;
|
||||||
|
|
||||||
// Argument order: has_received_client_actions, is_recoverable, has_retry_budget,
|
use super::{is_interactive_remote_command, stream_finished_llm_finished};
|
||||||
// can_attempt_resume_on_error, is_online.
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pre_action_failures_retry() {
|
fn response_finish_reason_reports_whether_the_llm_completed() {
|
||||||
assert_eq!(
|
assert!(stream_finished_llm_finished(&None));
|
||||||
recovery_action(false, true, true, true, true),
|
assert!(stream_finished_llm_finished(&Some(
|
||||||
RecoveryAction::RetryNow
|
stream_finished::Reason::Done(stream_finished::Done {})
|
||||||
);
|
)));
|
||||||
// Resume eligibility is irrelevant pre-actions.
|
assert!(stream_finished_llm_finished(&Some(
|
||||||
assert_eq!(
|
stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {})
|
||||||
recovery_action(false, true, true, false, true),
|
)));
|
||||||
RecoveryAction::RetryNow
|
assert!(!stream_finished_llm_finished(&Some(
|
||||||
);
|
stream_finished::Reason::Other(stream_finished::Other {})
|
||||||
}
|
)));
|
||||||
|
assert!(!stream_finished_llm_finished(&Some(
|
||||||
#[test]
|
stream_finished::Reason::LlmUnavailable(stream_finished::LlmUnavailable {})
|
||||||
fn pre_action_failures_wait_for_connectivity_when_offline() {
|
)));
|
||||||
assert_eq!(
|
|
||||||
recovery_action(false, true, true, true, false),
|
|
||||||
RecoveryAction::RetryWhenOnline
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pre_action_budget_exhaustion_is_terminal() {
|
|
||||||
// The request has already been retried MAX_RETRIES times; stop.
|
|
||||||
assert_eq!(
|
|
||||||
recovery_action(false, true, false, true, true),
|
|
||||||
RecoveryAction::Fail
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
recovery_action(false, true, false, true, false),
|
|
||||||
RecoveryAction::Fail
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_recoverable_pre_action_failure_is_terminal() {
|
|
||||||
assert_eq!(
|
|
||||||
recovery_action(false, false, true, true, true),
|
|
||||||
RecoveryAction::Fail
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn post_action_recoverable_failures_resume() {
|
|
||||||
assert_eq!(
|
|
||||||
recovery_action(true, true, true, true, true),
|
|
||||||
RecoveryAction::Resume
|
|
||||||
);
|
|
||||||
// Offline doesn't change the decision; the resume spawn waits for connectivity.
|
|
||||||
assert_eq!(
|
|
||||||
recovery_action(true, true, true, true, false),
|
|
||||||
RecoveryAction::Resume
|
|
||||||
);
|
|
||||||
// The in-request retry budget is irrelevant once actions have executed.
|
|
||||||
assert_eq!(
|
|
||||||
recovery_action(true, true, false, true, true),
|
|
||||||
RecoveryAction::Resume
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn post_action_failures_without_resume_eligibility_are_terminal() {
|
|
||||||
// Resume requests themselves run with can_attempt_resume_on_error=false,
|
|
||||||
// bounding recovery to a single resume.
|
|
||||||
assert_eq!(
|
|
||||||
recovery_action(true, true, true, false, true),
|
|
||||||
RecoveryAction::Fail
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_recoverable_post_action_failure_is_terminal() {
|
|
||||||
// A non-recoverable error (e.g. a client error) ends the conversation even
|
|
||||||
// after actions have executed.
|
|
||||||
assert_eq!(
|
|
||||||
recovery_action(true, false, true, true, true),
|
|
||||||
RecoveryAction::Fail
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ impl BlocklistAIController {
|
|||||||
if self
|
if self
|
||||||
.action_model
|
.action_model
|
||||||
.as_ref(ctx)
|
.as_ref(ctx)
|
||||||
.get_action_result(&result.id)
|
.get_action_result(conversation_id, &result.id)
|
||||||
.is_none()
|
.is_none()
|
||||||
{
|
{
|
||||||
self.action_model.update(ctx, |action_model, ctx| {
|
self.action_model.update(ctx, |action_model, ctx| {
|
||||||
|
|||||||
@@ -148,6 +148,19 @@ impl SlashCommandRequest {
|
|||||||
is_for_same_conversation: active_conversation_id
|
is_for_same_conversation: active_conversation_id
|
||||||
.is_some_and(|id| id == conversation_id),
|
.is_some_and(|id| id == conversation_id),
|
||||||
};
|
};
|
||||||
|
if controller.should_block_submission_for_unresolved_ask_user_question(
|
||||||
|
Some(conversation_id),
|
||||||
|
active_conversation_id,
|
||||||
|
ctx,
|
||||||
|
) {
|
||||||
|
controller.log_blocked_submission_for_unresolved_ask_user_question(
|
||||||
|
Some(conversation_id),
|
||||||
|
active_conversation_id,
|
||||||
|
is_queued_prompt,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(active_conversation_id) = active_conversation_id {
|
if let Some(active_conversation_id) = active_conversation_id {
|
||||||
controller.cancel_conversation_progress(
|
controller.cancel_conversation_progress(
|
||||||
active_conversation_id,
|
active_conversation_id,
|
||||||
@@ -181,7 +194,6 @@ impl SlashCommandRequest {
|
|||||||
entrypoint,
|
entrypoint,
|
||||||
is_auto_resume_after_error: false,
|
is_auto_resume_after_error: false,
|
||||||
}),
|
}),
|
||||||
/*can_attempt_resume_on_error*/ true,
|
|
||||||
is_queued_prompt,
|
is_queued_prompt,
|
||||||
ctx,
|
ctx,
|
||||||
) {
|
) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,14 +33,16 @@ use crate::ai::agent::conversation::{
|
|||||||
use crate::ai::agent::task::helper::{MessageExt, ToolCallExt};
|
use crate::ai::agent::task::helper::{MessageExt, ToolCallExt};
|
||||||
use crate::ai::agent::task::TaskId;
|
use crate::ai::agent::task::TaskId;
|
||||||
use crate::ai::agent::{
|
use crate::ai::agent::{
|
||||||
AIAgentActionId, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus,
|
AIAgentAction, AIAgentActionId, AIAgentExchange, AIAgentExchangeId, AIAgentInput,
|
||||||
CancellationReason, FinishedAIAgentOutput, MessageId, RenderableAIError, RequestCost,
|
AIAgentOutputStatus, CancellationReason, FinishedAIAgentOutput, MessageId, RenderableAIError,
|
||||||
Suggestions,
|
RequestCost, Suggestions,
|
||||||
};
|
};
|
||||||
use crate::ai::artifacts::Artifact;
|
use crate::ai::artifacts::Artifact;
|
||||||
use crate::ai::document::ai_document_model::AIDocumentModel;
|
use crate::ai::document::ai_document_model::AIDocumentModel;
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
use crate::ai::llms::LLMPreferences;
|
use crate::ai::llms::LLMPreferences;
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
|
||||||
use crate::input_suggestions::HistoryOrder;
|
use crate::input_suggestions::HistoryOrder;
|
||||||
use crate::persistence::model::{
|
use crate::persistence::model::{
|
||||||
AcpConversationData, AgentBackend, AgentConversation, AgentConversationData,
|
AcpConversationData, AgentBackend, AgentConversation, AgentConversationData,
|
||||||
@@ -561,6 +563,44 @@ impl BlocklistAIHistoryModel {
|
|||||||
conversation.write_updated_conversation_state(ctx);
|
conversation.write_updated_conversation_state(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn persist_active_provider_run_json(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
snapshot: Option<String>,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) -> Result<(), UpdateHistoryError> {
|
||||||
|
let conversation = self
|
||||||
|
.conversations_by_id
|
||||||
|
.get_mut(&conversation_id)
|
||||||
|
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
|
||||||
|
conversation.set_active_provider_run_json(snapshot);
|
||||||
|
conversation.write_updated_conversation_state(ctx);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rebind_provider_projection(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
task_id: &TaskId,
|
||||||
|
exchange_id: AIAgentExchangeId,
|
||||||
|
response_stream_id: ResponseStreamId,
|
||||||
|
terminal_surface_id: EntityId,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) -> Result<(), UpdateHistoryError> {
|
||||||
|
let conversation = self
|
||||||
|
.conversations_by_id
|
||||||
|
.get_mut(&conversation_id)
|
||||||
|
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
|
||||||
|
conversation.rebind_provider_projection(
|
||||||
|
task_id,
|
||||||
|
exchange_id,
|
||||||
|
response_stream_id,
|
||||||
|
terminal_surface_id,
|
||||||
|
ctx,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn update_cached_metadata_for_conversation(&mut self, conversation_id: AIConversationId) {
|
fn update_cached_metadata_for_conversation(&mut self, conversation_id: AIConversationId) {
|
||||||
let Some(conversation) = self.conversations_by_id.get(&conversation_id) else {
|
let Some(conversation) = self.conversations_by_id.get(&conversation_id) else {
|
||||||
return;
|
return;
|
||||||
@@ -1182,6 +1222,87 @@ impl BlocklistAIHistoryModel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn configured_agent_backend(
|
||||||
|
terminal_surface_id: EntityId,
|
||||||
|
is_viewing_shared_session: bool,
|
||||||
|
is_cli_agent_transcript: bool,
|
||||||
|
ctx: &AppContext,
|
||||||
|
) -> AgentBackend {
|
||||||
|
if is_viewing_shared_session
|
||||||
|
|| is_cli_agent_transcript
|
||||||
|
|| !cfg!(unix)
|
||||||
|
|| !FeatureFlag::AgentClientProtocol.is_enabled()
|
||||||
|
{
|
||||||
|
return AgentBackend::Provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = AISettings::as_ref(ctx);
|
||||||
|
if !*settings.acp_enabled.value() {
|
||||||
|
return AgentBackend::Provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
if let Some(llm_preferences) = ctx.try_get_singleton_model_as_ref::<LLMPreferences>() {
|
||||||
|
return llm_preferences.agent_backend_for_active_model(Some(terminal_surface_id), ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let providers = settings.enabled_acp_providers();
|
||||||
|
let [provider] = providers.as_slice() else {
|
||||||
|
return AgentBackend::Provider;
|
||||||
|
};
|
||||||
|
let agent_id = provider.agent_id.trim();
|
||||||
|
let agent_id = if agent_id.is_empty() {
|
||||||
|
"codex"
|
||||||
|
} else {
|
||||||
|
agent_id
|
||||||
|
};
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
let launch_fingerprint =
|
||||||
|
acp_launch_fingerprint(agent_id, &provider.command, &provider.args);
|
||||||
|
#[cfg(target_family = "wasm")]
|
||||||
|
let launch_fingerprint = String::new();
|
||||||
|
AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: provider.id.clone(),
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
launch_fingerprint,
|
||||||
|
session_id: None,
|
||||||
|
config_values: crate::ai::acp::AcpRuntimeModel::current_config_values(
|
||||||
|
&provider.config_options,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconciles a conversation without agent output with the currently enabled local runtime.
|
||||||
|
///
|
||||||
|
/// Agent views can create their initial conversation before the user changes runtime settings,
|
||||||
|
/// and a provider-less attempt can leave behind an error-only exchange. Refreshing here lets
|
||||||
|
/// either case use ACP without mixing successful provider output into an ACP-owned history.
|
||||||
|
pub(crate) fn refresh_conversation_backend_without_output(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
ctx: &AppContext,
|
||||||
|
) {
|
||||||
|
let Some(conversation) = self.conversation(&conversation_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(terminal_surface_id) = self.terminal_surface_id_for_conversation(&conversation_id)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let agent_backend = Self::configured_agent_backend(
|
||||||
|
terminal_surface_id,
|
||||||
|
conversation.is_viewing_shared_session(),
|
||||||
|
conversation.is_cli_agent_transcript(),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
if conversation.agent_backend() == &agent_backend {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(conversation) = self.conversation_mut(&conversation_id) {
|
||||||
|
conversation.set_agent_backend_if_no_output(agent_backend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Starts a new conversation in the given terminal surface's history, effectively marking the
|
/// Starts a new conversation in the given terminal surface's history, effectively marking the
|
||||||
/// existing conversation (if any) as completed.
|
/// existing conversation (if any) as completed.
|
||||||
///
|
///
|
||||||
@@ -1197,55 +1318,12 @@ impl BlocklistAIHistoryModel {
|
|||||||
is_cli_agent_transcript: bool,
|
is_cli_agent_transcript: bool,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) -> AIConversationId {
|
) -> AIConversationId {
|
||||||
let agent_backend = if !is_viewing_shared_session
|
let agent_backend = Self::configured_agent_backend(
|
||||||
&& !is_cli_agent_transcript
|
terminal_surface_id,
|
||||||
&& cfg!(unix)
|
is_viewing_shared_session,
|
||||||
&& FeatureFlag::AgentClientProtocol.is_enabled()
|
is_cli_agent_transcript,
|
||||||
{
|
ctx,
|
||||||
let settings = AISettings::as_ref(ctx);
|
);
|
||||||
if *settings.acp_enabled.value() {
|
|
||||||
let configured_agent_id = settings.acp_agent_id.value().trim();
|
|
||||||
let agent_id = if configured_agent_id.is_empty() {
|
|
||||||
"codex"
|
|
||||||
} else {
|
|
||||||
configured_agent_id
|
|
||||||
};
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
|
||||||
let launch_fingerprint = acp_launch_fingerprint(
|
|
||||||
agent_id,
|
|
||||||
settings.acp_agent_command.value(),
|
|
||||||
settings.acp_agent_args.value(),
|
|
||||||
);
|
|
||||||
#[cfg(target_family = "wasm")]
|
|
||||||
let launch_fingerprint = String::new();
|
|
||||||
AgentBackend::Acp(AcpConversationData {
|
|
||||||
agent_id: agent_id.to_string(),
|
|
||||||
launch_fingerprint,
|
|
||||||
session_id: None,
|
|
||||||
config_values: settings
|
|
||||||
.acp_agents
|
|
||||||
.value()
|
|
||||||
.iter()
|
|
||||||
.find(|agent| agent.id.eq_ignore_ascii_case(agent_id))
|
|
||||||
.map(|agent| {
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
|
||||||
if let Some(selection) = LLMPreferences::as_ref(ctx)
|
|
||||||
.selected_acp_config_for_agent(&agent.name, ctx)
|
|
||||||
{
|
|
||||||
return selection;
|
|
||||||
}
|
|
||||||
crate::ai::acp::AcpRuntimeModel::current_config_values(
|
|
||||||
&agent.config_options,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
AgentBackend::Provider
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
AgentBackend::Provider
|
|
||||||
};
|
|
||||||
let mut new_conversation = AIConversation::new_with_agent_backend(
|
let mut new_conversation = AIConversation::new_with_agent_backend(
|
||||||
is_viewing_shared_session,
|
is_viewing_shared_session,
|
||||||
is_cli_agent_transcript,
|
is_cli_agent_transcript,
|
||||||
@@ -1322,7 +1400,21 @@ impl BlocklistAIHistoryModel {
|
|||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) {
|
if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) {
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
let remote_log_context =
|
||||||
|
remote_status_log_context(conversation, &status, error.as_ref());
|
||||||
conversation.update_status_with_error(status, error, terminal_surface_id, ctx);
|
conversation.update_status_with_error(status, error, terminal_surface_id, ctx);
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
if let Some(context) = remote_log_context {
|
||||||
|
remote_logging::log_model_event(
|
||||||
|
ctx,
|
||||||
|
RemoteLogRecord {
|
||||||
|
level: RemoteLogLevel::Info,
|
||||||
|
message: "Agent conversation status changed".to_string(),
|
||||||
|
context,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1598,6 +1690,7 @@ impl BlocklistAIHistoryModel {
|
|||||||
|
|
||||||
let conversation_data = AgentConversationData {
|
let conversation_data = AgentConversationData {
|
||||||
agent_backend: source_conversation.agent_backend().for_fork(),
|
agent_backend: source_conversation.agent_backend().for_fork(),
|
||||||
|
active_provider_run_json: None,
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: Some(source_conversation.usage_metadata()),
|
conversation_usage_metadata: Some(source_conversation.usage_metadata()),
|
||||||
reverted_action_ids,
|
reverted_action_ids,
|
||||||
@@ -1762,6 +1855,7 @@ impl BlocklistAIHistoryModel {
|
|||||||
// be recomputed based on the retained exchanges in a follow-up.
|
// be recomputed based on the retained exchanges in a follow-up.
|
||||||
let conversation_data = AgentConversationData {
|
let conversation_data = AgentConversationData {
|
||||||
agent_backend: conversation.agent_backend().for_fork(),
|
agent_backend: conversation.agent_backend().for_fork(),
|
||||||
|
active_provider_run_json: None,
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: None,
|
conversation_usage_metadata: None,
|
||||||
reverted_action_ids,
|
reverted_action_ids,
|
||||||
@@ -1852,6 +1946,21 @@ impl BlocklistAIHistoryModel {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn apply_domain_tool_proposal(
|
||||||
|
&mut self,
|
||||||
|
response_stream_id: &ResponseStreamId,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
terminal_surface_id: EntityId,
|
||||||
|
action: AIAgentAction,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) -> Result<(), UpdateHistoryError> {
|
||||||
|
self.conversations_by_id
|
||||||
|
.get_mut(&conversation_id)
|
||||||
|
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?
|
||||||
|
.apply_domain_tool_proposal(response_stream_id, terminal_surface_id, action, ctx)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_conversation_cost_and_usage_for_request(
|
pub fn update_conversation_cost_and_usage_for_request(
|
||||||
&mut self,
|
&mut self,
|
||||||
conversation_id: AIConversationId,
|
conversation_id: AIConversationId,
|
||||||
@@ -2755,6 +2864,9 @@ fn merged_remote_child_placeholder_conversation_data(
|
|||||||
// Placeholder authoritative.
|
// Placeholder authoritative.
|
||||||
agent_backend: placeholder.agent_backend().clone(),
|
agent_backend: placeholder.agent_backend().clone(),
|
||||||
|
|
||||||
|
// Active process-local provider runs cannot be merged from a cloud transcript.
|
||||||
|
active_provider_run_json: None,
|
||||||
|
|
||||||
// Cloud authoritative.
|
// Cloud authoritative.
|
||||||
server_conversation_token: cloud_conversation
|
server_conversation_token: cloud_conversation
|
||||||
.server_conversation_token()
|
.server_conversation_token()
|
||||||
@@ -2817,6 +2929,46 @@ fn agent_id_key_from_persisted_data(conversation_data: &AgentConversationData) -
|
|||||||
conversation_data.run_id.as_deref()
|
conversation_data.run_id.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn remote_status_log_context(
|
||||||
|
conversation: &AIConversation,
|
||||||
|
new_status: &ConversationStatus,
|
||||||
|
error: Option<&RenderableAIError>,
|
||||||
|
) -> Option<serde_json::Value> {
|
||||||
|
let prev_status = conversation.status();
|
||||||
|
if prev_status == new_status {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"event": "agent_conversation_status_changed",
|
||||||
|
"conversation_id": conversation.id().to_string(),
|
||||||
|
"parent_conversation_id": conversation.parent_conversation_id().map(|id| id.to_string()),
|
||||||
|
"agent_id": conversation.orchestration_agent_id(),
|
||||||
|
"agent_name": conversation.agent_name(),
|
||||||
|
"harness_type": conversation.orchestration_harness_type(),
|
||||||
|
"is_child": conversation.parent_conversation_id().is_some(),
|
||||||
|
"is_remote_child": conversation.is_remote_child(),
|
||||||
|
"previous_status": conversation_status_label(prev_status),
|
||||||
|
"new_status": conversation_status_label(new_status),
|
||||||
|
"new_status_is_terminal": new_status.is_done(),
|
||||||
|
"error": error.map(remote_logging::sanitize_error),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn conversation_status_label(status: &ConversationStatus) -> &'static str {
|
||||||
|
match status {
|
||||||
|
ConversationStatus::InProgress => "in_progress",
|
||||||
|
ConversationStatus::Success => "success",
|
||||||
|
ConversationStatus::Error => "error",
|
||||||
|
ConversationStatus::TransientError => "transient_error",
|
||||||
|
ConversationStatus::Cancelled => "cancelled",
|
||||||
|
ConversationStatus::Blocked { .. } => "blocked",
|
||||||
|
ConversationStatus::WaitingForEvents => "waiting_for_events",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether an `UpdatedConversationStatus` event represents a restoration
|
/// Whether an `UpdatedConversationStatus` event represents a restoration
|
||||||
/// (the conversation was re-loaded for a terminal surface; the underlying
|
/// (the conversation was re-loaded for a terminal surface; the underlying
|
||||||
/// `ConversationStatus` did not change) or a real status set, in which case
|
/// `ConversationStatus` did not change) or a real status set, in which case
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ use crate::ai::agent::conversation::{
|
|||||||
ServerAIConversationMetadata,
|
ServerAIConversationMetadata,
|
||||||
};
|
};
|
||||||
use crate::ai::agent::{
|
use crate::ai::agent::{
|
||||||
AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, FinishedAIAgentOutput,
|
AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentExchange, AIAgentExchangeId,
|
||||||
RenderableAIError, Shared, TransientNetworkErrorKind, UserQueryMode,
|
AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType,
|
||||||
|
AIAgentOutputStatus, AIAgentText, AIAgentTextSection, AgentOutputText, FinishedAIAgentOutput,
|
||||||
|
MessageId, RenderableAIError, RunningCommand, Shared, TransientNetworkErrorKind, UserQueryMode,
|
||||||
};
|
};
|
||||||
use crate::ai::ambient_agents::{
|
use crate::ai::ambient_agents::{
|
||||||
conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus,
|
conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus,
|
||||||
@@ -78,6 +80,7 @@ fn acp_enabled_with_empty_command_selects_codex_backend() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
conversation.agent_backend(),
|
conversation.agent_backend(),
|
||||||
&AgentBackend::Acp(AcpConversationData {
|
&AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: "legacy".to_string(),
|
||||||
agent_id: "codex".to_string(),
|
agent_id: "codex".to_string(),
|
||||||
launch_fingerprint: crate::ai::acp::acp_launch_fingerprint("codex", "", &[]),
|
launch_fingerprint: crate::ai::acp::acp_launch_fingerprint("codex", "", &[]),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
@@ -88,6 +91,82 @@ fn acp_enabled_with_empty_command_selects_codex_backend() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enabling_acp_refreshes_a_provider_conversation_with_only_failed_output() {
|
||||||
|
let _acp_flag = FeatureFlag::AgentClientProtocol.override_enabled(true);
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
initialize_history_persistence_for_tests(&mut app);
|
||||||
|
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||||
|
settings
|
||||||
|
.acp_enabled
|
||||||
|
.set_value(false, ctx)
|
||||||
|
.expect("ACP setting should update");
|
||||||
|
});
|
||||||
|
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||||
|
let conversation_id = history_model.update(&mut app, |model, ctx| {
|
||||||
|
model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||||
|
});
|
||||||
|
history_model.read(&app, |model, _| {
|
||||||
|
assert_eq!(
|
||||||
|
model
|
||||||
|
.conversation(&conversation_id)
|
||||||
|
.expect("conversation should exist")
|
||||||
|
.agent_backend(),
|
||||||
|
&AgentBackend::Provider
|
||||||
|
);
|
||||||
|
});
|
||||||
|
history_model.update(&mut app, |model, _| {
|
||||||
|
let now = Local::now();
|
||||||
|
model
|
||||||
|
.conversation_mut(&conversation_id)
|
||||||
|
.expect("conversation should exist")
|
||||||
|
.append_root_exchange_for_test(AIAgentExchange {
|
||||||
|
id: AIAgentExchangeId::new(),
|
||||||
|
input: Vec::new(),
|
||||||
|
output_status: AIAgentOutputStatus::Finished {
|
||||||
|
finished_output: FinishedAIAgentOutput::Error {
|
||||||
|
output: None,
|
||||||
|
error: RenderableAIError::other("No AI provider configured", true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
added_message_ids: HashSet::new(),
|
||||||
|
start_time: now,
|
||||||
|
finish_time: Some(now),
|
||||||
|
time_to_first_token_ms: None,
|
||||||
|
working_directory: None,
|
||||||
|
model_id: LLMId::from("none"),
|
||||||
|
request_cost: None,
|
||||||
|
coding_model_id: LLMId::from("none"),
|
||||||
|
cli_agent_model_id: LLMId::from("none"),
|
||||||
|
computer_use_model_id: LLMId::from("none"),
|
||||||
|
response_initiator: None,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||||
|
settings
|
||||||
|
.acp_enabled
|
||||||
|
.set_value(true, ctx)
|
||||||
|
.expect("ACP setting should update");
|
||||||
|
});
|
||||||
|
history_model.update(&mut app, |model, ctx| {
|
||||||
|
model.refresh_conversation_backend_without_output(conversation_id, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
history_model.read(&app, |model, _| {
|
||||||
|
assert!(matches!(
|
||||||
|
model
|
||||||
|
.conversation(&conversation_id)
|
||||||
|
.expect("conversation should exist")
|
||||||
|
.agent_backend(),
|
||||||
|
AgentBackend::Acp(_)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper function to create a PersistedAIInput for testing
|
/// Helper function to create a PersistedAIInput for testing
|
||||||
fn create_persisted_query(
|
fn create_persisted_query(
|
||||||
query_text: &str,
|
query_text: &str,
|
||||||
@@ -144,6 +223,101 @@ fn repeated_command_steering_reuses_the_active_cli_subtask() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_tool_proposal_creates_exchange_for_tool_first_cli_turn() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
initialize_history_persistence_for_tests(&mut app);
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||||
|
let stream_id = ResponseStreamId::new_for_test();
|
||||||
|
let action_id = AIAgentActionId::from("monitor-tool-call".to_owned());
|
||||||
|
|
||||||
|
let (conversation_id, cli_task_id, action) =
|
||||||
|
history_model.update(&mut app, |model, ctx| {
|
||||||
|
let conversation_id =
|
||||||
|
model.start_new_conversation(terminal_view_id, false, false, false, ctx);
|
||||||
|
let root_task_id = model
|
||||||
|
.conversation(&conversation_id)
|
||||||
|
.expect("conversation should exist")
|
||||||
|
.get_root_task_id()
|
||||||
|
.clone();
|
||||||
|
model
|
||||||
|
.update_conversation_for_new_request_input(
|
||||||
|
RequestInput {
|
||||||
|
conversation_id,
|
||||||
|
input_messages: HashMap::from([(root_task_id, Vec::new())]),
|
||||||
|
working_directory: None,
|
||||||
|
model_id: LLMId::from("test-model"),
|
||||||
|
coding_model_id: LLMId::from("test-coding-model"),
|
||||||
|
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
|
||||||
|
computer_use_model_id: LLMId::from("test-computer-use-model"),
|
||||||
|
shared_session_response_initiator: None,
|
||||||
|
request_start_ts: Local::now(),
|
||||||
|
supported_tools_override: None,
|
||||||
|
},
|
||||||
|
stream_id.clone(),
|
||||||
|
terminal_view_id,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.expect("root response exchange should be recorded");
|
||||||
|
model.initialize_output_for_response_stream(
|
||||||
|
&stream_id,
|
||||||
|
conversation_id,
|
||||||
|
terminal_view_id,
|
||||||
|
warp_multi_agent_api::response_event::StreamInit {
|
||||||
|
request_id: "provider-request".to_owned(),
|
||||||
|
conversation_id: "provider-conversation".to_owned(),
|
||||||
|
run_id: "provider-run".to_owned(),
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
let cli_task_id = model
|
||||||
|
.create_cli_subagent_task_for_conversation(
|
||||||
|
BlockId::new(),
|
||||||
|
conversation_id,
|
||||||
|
terminal_view_id,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.expect("CLI subtask should be created");
|
||||||
|
let action = AIAgentAction {
|
||||||
|
id: action_id.clone(),
|
||||||
|
task_id: cli_task_id.clone(),
|
||||||
|
action: AIAgentActionType::FileGlob {
|
||||||
|
patterns: vec!["*.rs".to_owned()],
|
||||||
|
path: None,
|
||||||
|
},
|
||||||
|
requires_result: true,
|
||||||
|
tool_name: Some("file_glob".to_owned()),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.apply_domain_tool_proposal(
|
||||||
|
&stream_id,
|
||||||
|
conversation_id,
|
||||||
|
terminal_view_id,
|
||||||
|
action.clone(),
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.expect("tool-first CLI proposal should attach to a lazy exchange");
|
||||||
|
(conversation_id, cli_task_id, action)
|
||||||
|
});
|
||||||
|
|
||||||
|
history_model.read(&app, |model, _| {
|
||||||
|
let conversation = model
|
||||||
|
.conversation(&conversation_id)
|
||||||
|
.expect("conversation should exist");
|
||||||
|
let cli_task = conversation
|
||||||
|
.get_task(&cli_task_id)
|
||||||
|
.expect("CLI subtask should exist");
|
||||||
|
assert_eq!(cli_task.exchanges_len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
conversation.exchange_id_for_action(&action.id),
|
||||||
|
cli_task.last_exchange().map(|exchange| exchange.id)
|
||||||
|
);
|
||||||
|
assert!(conversation.contains_action(&action.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deactivating_cli_subtask_clears_activity_without_deleting_task() {
|
fn deactivating_cli_subtask_clears_activity_without_deleting_task() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
@@ -193,6 +367,125 @@ fn deactivating_cli_subtask_clears_activity_without_deleting_task() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_command_assessment_survives_cli_subtask_deactivation_on_root() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
initialize_history_persistence_for_tests(&mut app);
|
||||||
|
let terminal_view_id = EntityId::new();
|
||||||
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||||
|
let block_id = BlockId::new();
|
||||||
|
let assessment_output = "The command completed successfully.";
|
||||||
|
|
||||||
|
history_model.update(&mut app, |model, ctx| {
|
||||||
|
let conversation_id =
|
||||||
|
model.start_new_conversation(terminal_view_id, false, false, false, ctx);
|
||||||
|
let cli_task_id = model
|
||||||
|
.create_cli_subagent_task_for_conversation(
|
||||||
|
block_id.clone(),
|
||||||
|
conversation_id,
|
||||||
|
terminal_view_id,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.expect("CLI subtask should be created");
|
||||||
|
|
||||||
|
let monitor_exchange =
|
||||||
|
create_exchange_with_query("Check the command status.", Local::now(), None);
|
||||||
|
let monitor_exchange_id = monitor_exchange.id;
|
||||||
|
let conversation = model
|
||||||
|
.conversation_mut(&conversation_id)
|
||||||
|
.expect("conversation should exist");
|
||||||
|
conversation
|
||||||
|
.append_task_exchange_for_test(
|
||||||
|
&cli_task_id,
|
||||||
|
monitor_exchange,
|
||||||
|
terminal_view_id,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.expect("monitor exchange should be appended to the CLI task");
|
||||||
|
|
||||||
|
let now = Local::now();
|
||||||
|
let assessment_exchange = AIAgentExchange {
|
||||||
|
id: AIAgentExchangeId::new(),
|
||||||
|
input: vec![AIAgentInput::CommandCompletionAssessment {
|
||||||
|
prompt: "Assess the completed command.".to_string(),
|
||||||
|
context: Arc::from([]),
|
||||||
|
completed_command: RunningCommand {
|
||||||
|
command: "cargo test -p galaxy".to_string(),
|
||||||
|
block_id: block_id.clone(),
|
||||||
|
grid_contents: "test result: ok".to_string(),
|
||||||
|
cursor: String::new(),
|
||||||
|
requested_command_id: None,
|
||||||
|
is_alt_screen_active: false,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
output_status: AIAgentOutputStatus::Finished {
|
||||||
|
finished_output: FinishedAIAgentOutput::Success {
|
||||||
|
output: Shared::new(AIAgentOutput {
|
||||||
|
messages: vec![AIAgentOutputMessage {
|
||||||
|
id: MessageId::new("assessment-output".to_string()),
|
||||||
|
message: AIAgentOutputMessageType::Text(AIAgentText {
|
||||||
|
sections: vec![AIAgentTextSection::PlainText {
|
||||||
|
text: AgentOutputText::from(assessment_output.to_string()),
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
citations: vec![],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
added_message_ids: HashSet::new(),
|
||||||
|
start_time: now,
|
||||||
|
finish_time: Some(now),
|
||||||
|
time_to_first_token_ms: None,
|
||||||
|
working_directory: None,
|
||||||
|
model_id: LLMId::from("test-model"),
|
||||||
|
request_cost: None,
|
||||||
|
coding_model_id: LLMId::from("test-coding-model"),
|
||||||
|
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
|
||||||
|
computer_use_model_id: LLMId::from("test-computer-use-model"),
|
||||||
|
response_initiator: None,
|
||||||
|
};
|
||||||
|
let assessment_exchange_id = assessment_exchange.id;
|
||||||
|
model
|
||||||
|
.conversation_mut(&conversation_id)
|
||||||
|
.expect("conversation should exist")
|
||||||
|
.append_root_exchange_for_test(assessment_exchange);
|
||||||
|
|
||||||
|
model
|
||||||
|
.deactivate_cli_subagent_task_for_conversation(&block_id, conversation_id)
|
||||||
|
.expect("CLI subtask should deactivate");
|
||||||
|
|
||||||
|
let conversation = model
|
||||||
|
.conversation(&conversation_id)
|
||||||
|
.expect("conversation should still exist");
|
||||||
|
assert!(!conversation.has_active_subagent());
|
||||||
|
let cli_task = conversation
|
||||||
|
.get_task(&cli_task_id)
|
||||||
|
.expect("CLI task should be retained after deactivation");
|
||||||
|
assert_eq!(cli_task.exchanges_len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
cli_task.last_exchange().map(|exchange| exchange.id),
|
||||||
|
Some(monitor_exchange_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
let root_exchange = conversation
|
||||||
|
.latest_visible_exchange()
|
||||||
|
.expect("root assessment output should remain visible");
|
||||||
|
assert_eq!(root_exchange.id, assessment_exchange_id);
|
||||||
|
assert!(matches!(
|
||||||
|
root_exchange.input.as_slice(),
|
||||||
|
[AIAgentInput::CommandCompletionAssessment { .. }]
|
||||||
|
));
|
||||||
|
assert!(root_exchange.input[0].display_query().is_none());
|
||||||
|
assert_eq!(
|
||||||
|
root_exchange.format_output_for_copy(None),
|
||||||
|
assessment_output
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn monitoring_a_different_block_preserves_completed_cli_task_history() {
|
fn monitoring_a_different_block_preserves_completed_cli_task_history() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
|
|||||||
@@ -782,7 +782,11 @@ impl AskUserQuestionView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ctx.subscribe_to_model(&action_model, |me, _, event, ctx| {
|
ctx.subscribe_to_model(&action_model, |me, _, event, ctx| {
|
||||||
if event.action_id() != me.action_id() {
|
if event.action_id() != me.action_id()
|
||||||
|
|| event
|
||||||
|
.conversation_id()
|
||||||
|
.is_some_and(|conversation_id| conversation_id != me.conversation_id)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,7 +883,8 @@ impl AskUserQuestionView {
|
|||||||
/// conversations still render deterministically.
|
/// conversations still render deterministically.
|
||||||
fn action_status(&self, app: &AppContext) -> Option<AIActionStatus> {
|
fn action_status(&self, app: &AppContext) -> Option<AIActionStatus> {
|
||||||
let action_model = self.action_model.as_ref(app);
|
let action_model = self.action_model.as_ref(app);
|
||||||
if let Some(status) = action_model.get_action_status(self.action_id()) {
|
if let Some(status) = action_model.get_action_status(self.conversation_id, self.action_id())
|
||||||
|
{
|
||||||
return Some(status);
|
return Some(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ pub enum CodeDiffState {
|
|||||||
/// The diff is received, but is queued for interaction behind another action.
|
/// The diff is received, but is queued for interaction behind another action.
|
||||||
Queued,
|
Queued,
|
||||||
/// The user is reviewing (and possibly editing) the code diff.
|
/// The user is reviewing (and possibly editing) the code diff.
|
||||||
/// Unlike requested commands, a [`CodeDiffView`] is only created upon stream completion.
|
/// The view is created as soon as the requested edit is present in streaming output.
|
||||||
WaitingForUser,
|
WaitingForUser,
|
||||||
/// If the payload is some, the code diff was accepted but the individual file changes have not
|
/// If the payload is some, the code diff was accepted but the individual file changes have not
|
||||||
/// been fully computed and saved yet. We cache the accepted diff state to collect unified diffs
|
/// been fully computed and saved yet. We cache the accepted diff state to collect unified diffs
|
||||||
@@ -695,12 +695,26 @@ impl CodeDiffView {
|
|||||||
session_platform,
|
session_platform,
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
let action_id = (*action_id).clone();
|
||||||
|
|
||||||
ctx.subscribe_to_model(
|
ctx.subscribe_to_model(
|
||||||
&action_model,
|
&action_model,
|
||||||
move |me, action_model, event, ctx| match event {
|
move |me, action_model, event, ctx| match event {
|
||||||
BlocklistAIActionEvent::FinishedAction { action_id, .. } if !me.is_complete() => {
|
BlocklistAIActionEvent::FinishedAction {
|
||||||
match action_model.as_ref(ctx).get_action_status(&me.action_id) {
|
action_id: event_action_id,
|
||||||
|
conversation_id: event_conversation_id,
|
||||||
|
..
|
||||||
|
} if !me.is_complete()
|
||||||
|
&& *event_action_id == me.action_id
|
||||||
|
&& me.identifiers.client_conversation_id == Some(*event_conversation_id) =>
|
||||||
|
{
|
||||||
|
let Some(conversation_id) = me.identifiers.client_conversation_id else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match action_model
|
||||||
|
.as_ref(ctx)
|
||||||
|
.get_action_status(conversation_id, &me.action_id)
|
||||||
|
{
|
||||||
Some(AIActionStatus::Blocked) => {
|
Some(AIActionStatus::Blocked) => {
|
||||||
me.state = CodeDiffState::WaitingForUser;
|
me.state = CodeDiffState::WaitingForUser;
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ pub(super) mod search_codebase;
|
|||||||
pub(crate) mod search_results_common;
|
pub(crate) mod search_results_common;
|
||||||
pub(crate) mod suggested_unit_tests;
|
pub(crate) mod suggested_unit_tests;
|
||||||
pub(super) mod summarization;
|
pub(super) mod summarization;
|
||||||
|
pub(crate) mod tool_pane;
|
||||||
pub(super) mod web_fetch;
|
pub(super) mod web_fetch;
|
||||||
pub(super) mod web_search;
|
pub(super) mod web_search;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user