From 7c2888e2610296d4619d0c3b45583f945ecc2d39 Mon Sep 17 00:00:00 2001 From: liqun Date: Fri, 23 Jan 2026 18:46:03 +0800 Subject: [PATCH 01/10] add variables to code generator prompt --- AGENTS.md | 292 ++++++++++++++++++ docs/design/code-interpreter-vars.md | 62 ++++ taskweaver/ces/common.py | 1 + taskweaver/ces/environment.py | 2 + taskweaver/ces/kernel/ctx_magic.py | 1 + taskweaver/ces/runtime/context.py | 56 ++++ taskweaver/ces/runtime/executor.py | 1 + taskweaver/code_interpreter/code_executor.py | 6 + .../code_interpreter/code_generator.py | 35 ++- .../code_interpreter/code_interpreter.py | 7 + taskweaver/memory/attachment.py | 3 + taskweaver/utils/__init__.py | 12 + 12 files changed, 473 insertions(+), 5 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/design/code-interpreter-vars.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8171f6e70 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,292 @@ +# AGENTS.md - TaskWeaver Development Guide + +This document provides guidance for AI coding agents working on the TaskWeaver codebase. + +## Project Overview + +TaskWeaver is a **code-first agent framework** for data analytics tasks. It uses Python 3.10+ and follows a modular architecture with dependency injection (using `injector`). + +## Build & Development Commands + +### Installation +```bash +# Use the existing conda environment +conda activate taskweaver + +# Or create a new one +conda create -n taskweaver python=3.10 +conda activate taskweaver + +# Install dependencies +pip install -r requirements.txt + +# Install in editable mode +pip install -e . +``` + +**Note**: The project uses a conda environment named `taskweaver`. + +### Running Tests +```bash +# Run all unit tests +pytest tests/unit_tests -v + +# Run a single test file +pytest tests/unit_tests/test_plugin.py -v + +# Run a specific test function +pytest tests/unit_tests/test_plugin.py::test_load_plugin_yaml -v + +# Run tests with coverage +pytest tests/unit_tests -v --cov=taskweaver --cov-report=html + +# Collect tests without running (useful for verification) +pytest tests/unit_tests --collect-only +``` + +### Linting & Formatting +```bash +# Run pre-commit hooks (autoflake, isort, black, flake8) +pre-commit run --all-files + +# Run individual tools +black --config=.linters/pyproject.toml . +isort --settings-path=.linters/pyproject.toml . +flake8 --config=.linters/tox.ini taskweaver/ +``` + +### Running the Application +```bash +# CLI mode +python -m taskweaver -p ./project/ + +# As a module +python -m taskweaver +``` + +## Code Style Guidelines + +### Formatting Configuration +- **Line length**: 120 characters (configured in `.linters/pyproject.toml`) +- **Formatter**: Black with `--config=.linters/pyproject.toml` +- **Import sorting**: isort with `profile = "black"` + +### Import Organization +```python +# Standard library imports first +import os +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +# Third-party imports +from injector import inject + +# Local imports (known_first_party = ["taskweaver"]) +from taskweaver.config.config_mgt import AppConfigSource +from taskweaver.logging import TelemetryLogger +``` + +### Type Annotations +- **Required**: All function parameters and return types must have type hints +- **Use `Optional[T]`** for nullable types +- **Use `List`, `Dict`, `Tuple`** from `typing` module +- **Dataclasses** are preferred for structured data + +```python +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +@dataclass +class Post: + id: str + send_from: str + send_to: str + message: str + attachment_list: List[Attachment] + + @staticmethod + def create( + message: Optional[str], + send_from: str, + send_to: str = "Unknown", + ) -> Post: + ... +``` + +### Naming Conventions +- **Classes**: PascalCase (`CodeGenerator`, `PluginRegistry`) +- **Functions/methods**: snake_case (`compose_prompt`, `get_attachment`) +- **Variables**: snake_case (`plugin_pool`, `chat_history`) +- **Constants**: UPPER_SNAKE_CASE (`MAX_RETRY_COUNT`) +- **Private members**: prefix with underscore (`_configure`, `_get_config_value`) +- **Config classes**: suffix with `Config` (`PlannerConfig`, `RoleConfig`) + +### Dependency Injection Pattern +TaskWeaver uses the `injector` library for DI. Follow this pattern: + +```python +from injector import inject, Module, provider + +class MyConfig(ModuleConfig): + def _configure(self) -> None: + self._set_name("my_module") + self.some_setting = self._get_str("setting_name", "default_value") + +class MyService: + @inject + def __init__( + self, + config: MyConfig, + logger: TelemetryLogger, + other_dependency: OtherService, + ): + self.config = config + self.logger = logger +``` + +### Error Handling +- Use specific exception types when possible +- Log errors with context before re-raising +- Use assertions for internal invariants + +```python +try: + result = self.llm_api.chat_completion_stream(...) +except (JSONDecodeError, AssertionError) as e: + self.logger.error(f"Failed to parse LLM output due to {str(e)}") + self.tracing.set_span_status("ERROR", str(e)) + raise +``` + +### Docstrings +Use triple-quoted docstrings for classes and public methods: + +```python +def get_embeddings(self, strings: List[str]) -> List[List[float]]: + """ + Embedding API + + :param strings: list of strings to be embedded + :return: list of embeddings + """ +``` + +### Trailing Commas +Always use trailing commas in multi-line structures (enforced by `add-trailing-comma`): + +```python +app_injector = Injector( + [LoggingModule, PluginModule], # trailing comma +) + +config = { + "key1": "value1", + "key2": "value2", # trailing comma +} +``` + +## Project Structure + +``` +taskweaver/ +├── app/ # Application entry points and session management +├── ces/ # Code execution service +├── chat/ # Chat interfaces (console, web) +├── cli/ # CLI implementation +├── code_interpreter/ # Code generation and interpretation +├── config/ # Configuration management +├── ext_role/ # Extended roles (web_search, image_reader, etc.) +├── llm/ # LLM integrations (OpenAI, Anthropic, etc.) +├── logging/ # Logging and telemetry +├── memory/ # Conversation memory and attachments +├── misc/ # Utilities and component registry +├── module/ # Core modules (tracing, events) +├── planner/ # Planning logic +├── plugin/ # Plugin system +├── role/ # Role base classes +├── session/ # Session management +├── utils/ # Helper utilities +└── workspace/ # Workspace management + +tests/ +└── unit_tests/ # Unit tests (pytest) + ├── data/ # Test fixtures (plugins, prompts, examples) + └── ces/ # Code execution tests +``` + +### Module and Role Overview (what lives where) + +- **app/**: Bootstraps dependency injection; wires TaskWeaverApp, SessionManager, config binding. +- **session/**: Orchestrates Planner + worker roles, memory, workspace management, event emitter, tracing. +- **planner/**: Planner role; LLM-powered task decomposition and planning logic. +- **code_interpreter/**: Code generation and execution (full, CLI-only, plugin-only); code verification/AST checks. +- **memory/**: Conversation history, rounds, posts, attachments, experiences; RoundCompressor utilities. +- **llm/**: LLM API facades; providers include OpenAI/Azure, Anthropic, Ollama, Google GenAI, Qwen, ZhipuAI, Groq, Azure ML, mock; embeddings via OpenAI/Azure, Ollama, Google GenAI, sentence_transformers, Qwen, ZhipuAI. +- **plugin/**: Plugin base classes and registry/context for function-style plugins. +- **role/**: Core role abstractions, RoleRegistry, PostTranslator. +- **ext_role/**: Extended roles (web_search, web_explorer, image_reader, document_retriever, recepta, echo). +- **module/**: Core modules like tracing and event_emitter wiring. +- **logging/**: TelemetryLogger and logging setup. +- **workspace/**: Session-scoped working directories and execution cwd helpers. + +## Testing Patterns + +### Using Fixtures +```python +import pytest +from injector import Injector + +@pytest.fixture() +def app_injector(request: pytest.FixtureRequest): + from taskweaver.config.config_mgt import AppConfigSource + config = {"llm.api_key": "test_key"} + app_injector = Injector([LoggingModule, PluginModule]) + app_config = AppConfigSource(config=config) + app_injector.binder.bind(AppConfigSource, to=app_config) + return app_injector +``` + +### Test Markers +```python +@pytest.mark.app_config({"custom.setting": "value"}) +def test_with_custom_config(app_injector): + ... +``` + +## Flake8 Ignores +The following are intentionally ignored (see `.linters/tox.ini`): +- `E402`: Module level import not at top of file +- `W503`: Line break before binary operator +- `W504`: Line break after binary operator +- `E203`: Whitespace before ':' +- `F401`: Import not used (only in `__init__.py`) + +## Key Patterns + +### Creating Unique IDs +```python +from taskweaver.utils import create_id +post_id = "post-" + create_id() # Format: post-YYYYMMDD-HHMMSS- +``` + +### Reading/Writing YAML +```python +from taskweaver.utils import read_yaml, write_yaml +data = read_yaml("path/to/file.yaml") +write_yaml("path/to/file.yaml", data) +``` + +### Configuration Access +```python +class MyConfig(ModuleConfig): + def _configure(self) -> None: + self._set_name("my_module") + self.enabled = self._get_bool("enabled", False) + self.path = self._get_path("base_path", "/default/path") + self.model = self._get_str("model", None, required=False) +``` + +## CI/CD +- Tests run on Python 3.11 via GitHub Actions +- Pre-commit hooks include: autoflake, isort, black, flake8, gitleaks, detect-secrets +- All PRs to `main` trigger the pytest workflow diff --git a/docs/design/code-interpreter-vars.md b/docs/design/code-interpreter-vars.md new file mode 100644 index 000000000..18f4cd53e --- /dev/null +++ b/docs/design/code-interpreter-vars.md @@ -0,0 +1,62 @@ +# Code Interpreter Visible Variable Surfacing + +## Problem +The code interpreter generates Python in a persistent kernel but the prompt does not explicitly remind the model which variables already exist in that kernel. This can lead to redundant redefinitions or missed reuse of prior results. We want to surface only the newly defined (non-library) variables to the model in subsequent turns. + +## Goals +- Capture the current user/kernel-visible variables after each execution (excluding standard libs and plugins). +- Propagate these variables to the code interpreter’s prompt so it can reuse them. +- Keep noise low: skip modules/functions and internal/builtin names; truncate large reprs. +- Maintain backward compatibility; do not break existing attachments or execution flow. + +## Non-Goals +- Full introspection of module internals or large data snapshots. +- Persisting variables across sessions beyond current conversation. + +## Design Overview +1) **Collect kernel variables after execution** + - In the IPython magics layer (`_taskweaver_exec_post_check`) call a context helper to extract visible variables from `local_ns`. + - Filtering rules: + - Skip names starting with `_`. + - Skip builtins and common libs: `__builtins__`, `In`, `Out`, `get_ipython`, `exit`, `quit`, `pd`, `np`, `plt`. + - Skip modules and any defined functions (only keep data-bearing variables). + - For other values, store `(name, repr(value))`, truncated to 500 chars and fall back to `` on repr errors. + - Store the snapshot on `ExecutorPluginContext.latest_variables`. + +2) **Return variables with execution result** + - `Executor.get_post_execution_state` now includes `variables` (list of `(name, repr)` tuples). + - `Environment._parse_exec_result` copies these into `ExecutionResult.variables` (added to dataclass). + +3) **Surface variables to user and prompt** + - `CodeExecutor.format_code_output` renders available variables when there is no explicit result/output, using `pretty_repr` to keep lines concise. + - `CodeInterpreter.reply` attaches a new `session_variables` attachment (JSON list of tuples) when variables are present. + - `CodeGenerator.compose_conversation` ignores this attachment in assistant-message rendering but includes it in feedback via `format_code_feedback`, adding an “Available Variables” section for the model’s context. + +4) **Attachment type** + - Added `AttachmentType.session_variables` to carry the variable snapshot per execution. + +## Open Items / Next Steps +- Wire the variables directly into the final user turn’s prompt text (e.g., under a “Currently available variables” block) to make reuse even clearer. +- Revisit filtering to ensure we skip large data/DF previews (could add size/type caps). +- Validate end-to-end with unit tests for: variable capture, attachment propagation, prompt inclusion, and formatting. + +## Files Touched +- `taskweaver/ces/runtime/context.py` — collect and store visible variables. +- `taskweaver/ces/runtime/executor.py` — expose variables in post-execution state. +- `taskweaver/ces/environment.py` — carry variables into `ExecutionResult`. +- `taskweaver/ces/common.py` — add `variables` to `ExecutionResult` dataclass. +- `taskweaver/memory/attachment.py` — add `session_variables` attachment type. +- `taskweaver/code_interpreter/code_interpreter/code_interpreter.py` — attach captured vars to posts. +- `taskweaver/code_interpreter/code_interpreter/code_generator.py` — ignore var attachments in assistant text; include in feedback. +- `taskweaver/code_interpreter/code_executor.py` — display available variables when no explicit output. +- `taskweaver/utils/__init__.py` — add `pretty_repr` helper for safe truncation. + +## Rationale +- Keeps the model aware of live state without inflating prompts with full outputs. +- Avoids re-importing/recomputing when variables already exist. +- Uses attachments so downstream consumers (UI/logs) can also show the state. + +## Risks / Mitigations +- **Large values**: truncated repr and filtered types keep prompt size bounded; consider type-based caps later. +- **Noise from libs**: explicit ignore list for common imports; can expand as needed. +- **Compatibility**: new attachment type is additive; existing flows remain unchanged. diff --git a/taskweaver/ces/common.py b/taskweaver/ces/common.py index ebcd89fef..5fd5f1f7e 100644 --- a/taskweaver/ces/common.py +++ b/taskweaver/ces/common.py @@ -68,6 +68,7 @@ class ExecutionResult: log: List[Tuple[str, str, str]] = dataclasses.field(default_factory=list) artifact: List[ExecutionArtifact] = dataclasses.field(default_factory=list) + variables: List[Tuple[str, str]] = dataclasses.field(default_factory=list) class Client(ABC): diff --git a/taskweaver/ces/environment.py b/taskweaver/ces/environment.py index e5f30ed16..5b2cfe868 100644 --- a/taskweaver/ces/environment.py +++ b/taskweaver/ces/environment.py @@ -697,6 +697,8 @@ def _parse_exec_result( preview=artifact_dict["preview"], ) result.artifact.append(artifact_item) + elif key == "variables": + result.variables = value else: pass diff --git a/taskweaver/ces/kernel/ctx_magic.py b/taskweaver/ces/kernel/ctx_magic.py index efc672486..cf9dba4e5 100644 --- a/taskweaver/ces/kernel/ctx_magic.py +++ b/taskweaver/ces/kernel/ctx_magic.py @@ -58,6 +58,7 @@ def _taskweaver_exec_pre_check(self, line: str): def _taskweaver_exec_post_check(self, line: str, local_ns: Dict[str, Any]): if "_" in local_ns: self.executor.ctx.set_output(local_ns["_"]) + self.executor.ctx.extract_visible_variables(local_ns) return fmt_response(True, "", self.executor.get_post_execution_state()) @cell_magic diff --git a/taskweaver/ces/runtime/context.py b/taskweaver/ces/runtime/context.py index e5ad1c142..1c4b38818 100644 --- a/taskweaver/ces/runtime/context.py +++ b/taskweaver/ces/runtime/context.py @@ -1,7 +1,14 @@ import os +import types from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +try: + import numpy as _np # type: ignore +except Exception: # pragma: no cover - optional dependency + _np = None + from taskweaver.module.prompt_util import PromptUtil +from taskweaver.plugin.base import Plugin from taskweaver.plugin.context import ArtifactType, LogErrorLevel, PluginContext if TYPE_CHECKING: @@ -15,6 +22,7 @@ def __init__(self, executor: Any) -> None: self.artifact_list: List[Dict[str, str]] = [] self.log_messages: List[Tuple[LogErrorLevel, str, str]] = [] self.output: List[Tuple[str, str]] = [] + self.latest_variables: List[Tuple[str, str]] = [] @property def execution_id(self) -> str: @@ -147,6 +155,54 @@ def get_session_var( return self.executor.session_var[variable_name] return default + def extract_visible_variables(self, local_ns: Dict[str, Any]) -> List[Tuple[str, str]]: + ignore_names = { + "__builtins__", + "In", + "Out", + "get_ipython", + "exit", + "quit", + "pd", + "np", + "plt", + } + + visible: List[Tuple[str, str]] = [] + for name, value in local_ns.items(): + if name.startswith("_") or name in ignore_names: + continue + + if isinstance(value, (types.ModuleType, types.FunctionType)): + continue + + if isinstance(value, Plugin) or getattr(value, "__module__", "").startswith("taskweaver_ext.plugin"): + continue + + if _np is not None and isinstance(value, _np.ndarray): + try: + rendered = _np.array2string( + value, + max_line_width=120, + threshold=20, + edgeitems=3, + ) + rendered = f"ndarray shape={value.shape} dtype={value.dtype} value={rendered}" + except Exception: + rendered = "" + visible.append((name, rendered[:500])) + continue + + try: + rendered = repr(value) + except Exception: + rendered = "" + + visible.append((name, rendered[:500])) + + self.latest_variables = visible + return visible + def wrap_text_with_delimiter_temporal(self, text: str) -> str: """wrap text with delimiter""" return PromptUtil.wrap_text_with_delimiter( diff --git a/taskweaver/ces/runtime/executor.py b/taskweaver/ces/runtime/executor.py index 6359c3ba6..9a7420348 100644 --- a/taskweaver/ces/runtime/executor.py +++ b/taskweaver/ces/runtime/executor.py @@ -226,6 +226,7 @@ def get_post_execution_state(self): "artifact": self.ctx.artifact_list, "log": self.ctx.log_messages, "output": self.ctx.get_normalized_output(), + "variables": list(self.ctx.latest_variables), } def log(self, level: LogErrorLevel, message: str): diff --git a/taskweaver/code_interpreter/code_executor.py b/taskweaver/code_interpreter/code_executor.py index 40974950d..f7859f0d5 100644 --- a/taskweaver/code_interpreter/code_executor.py +++ b/taskweaver/code_interpreter/code_executor.py @@ -10,6 +10,7 @@ from taskweaver.module.tracing import Tracing, get_tracer, tracing_decorator from taskweaver.plugin.context import ArtifactType from taskweaver.session import SessionMetadata +from taskweaver.utils import pretty_repr TRUNCATE_CHAR_LENGTH = 1500 @@ -190,6 +191,11 @@ def format_code_output( lines.append( "The result of above Python code after execution is:\n" + str(output), ) + elif len(result.variables) > 0: + lines.append("The following variables are currently available in the Python session:\n") + for name, val in result.variables: + lines.append(f"- {name}: {pretty_repr(val, limit=500)}") + lines.append("") elif result.is_success: if len(result.stdout) > 0: lines.append( diff --git a/taskweaver/code_interpreter/code_interpreter/code_generator.py b/taskweaver/code_interpreter/code_interpreter/code_generator.py index 5800fb1c0..321631dc3 100644 --- a/taskweaver/code_interpreter/code_interpreter/code_generator.py +++ b/taskweaver/code_interpreter/code_interpreter/code_generator.py @@ -1,7 +1,7 @@ import datetime import json import os -from typing import List, Optional +from typing import List, Literal, Optional, Union from injector import inject @@ -119,10 +119,10 @@ def compose_verification_requirements( + ", ".join([f"{module}" for module in self.allowed_modules]), ) - if len(self.allowed_modules) == 0: + if self.allowed_modules is not None and len(self.allowed_modules) == 0: requirements.append(f"- {self.role_name} cannot import any Python modules.") - if len(self.blocked_functions) > 0: + if self.blocked_functions is not None and len(self.blocked_functions) > 0: requirements.append( f"- {self.role_name} cannot use the following Python functions: " + ", ".join([f"{function}" for function in self.blocked_functions]), @@ -207,10 +207,11 @@ def compose_conversation( AttachmentType.code_error, AttachmentType.execution_status, AttachmentType.execution_result, + AttachmentType.session_variables, ] is_first_post = True - last_post: Post = None + last_post: Optional[Post] = None for round_index, conversation_round in enumerate(rounds): for post_index, post in enumerate(conversation_round.post_list): # compose user query @@ -292,10 +293,24 @@ def compose_conversation( if len(user_message) > 0: # add requirements to the last user message if is_final_post and add_requirements: + available_vars_section = "" + session_vars = post.get_attachment(AttachmentType.session_variables) + if session_vars is not None and len(session_vars) > 0: + try: + decoded_vars = json.loads(session_vars[0].content) + if isinstance(decoded_vars, list) and len(decoded_vars) > 0: + formatted_vars = "\n".join([f"- {name}: {value}" for name, value in decoded_vars]) + available_vars_section = ( + "\nCurrently available variables in the Python session:\n" + formatted_vars + ) + except Exception: + pass user_message += "\n" + self.query_requirements_template.format( CODE_GENERATION_REQUIREMENTS=self.compose_verification_requirements(), ROLE_NAME=self.role_name, ) + if available_vars_section: + user_message += available_vars_section chat_history.append( format_chat_message(role="user", message=user_message), ) @@ -365,7 +380,7 @@ def reply( }, ) - def early_stop(_type: AttachmentType, value: str) -> bool: + def early_stop(_type: Union[AttachmentType, Literal["message", "send_to"]], value: str) -> bool: if _type in [AttachmentType.reply_content]: return True else: @@ -443,6 +458,7 @@ def format_code_feedback(post: Post) -> str: feedback = "" verification_status = "" execution_status = "" + variable_lines = [] for attachment in post.attachment_list: if attachment.type == AttachmentType.verification and attachment.content == "CORRECT": feedback += "## Verification\nCode verification has been passed.\n" @@ -466,4 +482,13 @@ def format_code_feedback(post: Post) -> str: execution_status = "FAILURE" elif attachment.type == AttachmentType.execution_result and execution_status != "NONE": feedback += f"{attachment.content}\n" + elif attachment.type == AttachmentType.session_variables: + try: + variables = json.loads(attachment.content) + if isinstance(variables, list) and len(variables) > 0: + variable_lines.extend([f"- {name}: {value}" for name, value in variables]) + except Exception: + pass + if len(variable_lines) > 0: + feedback += "## Available Variables\n" + "\n".join(variable_lines) + "\n" return feedback diff --git a/taskweaver/code_interpreter/code_interpreter/code_interpreter.py b/taskweaver/code_interpreter/code_interpreter/code_interpreter.py index 82dc9da6e..9770da325 100644 --- a/taskweaver/code_interpreter/code_interpreter/code_interpreter.py +++ b/taskweaver/code_interpreter/code_interpreter/code_interpreter.py @@ -1,3 +1,4 @@ +import json import os from typing import Dict, Literal, Optional @@ -247,6 +248,12 @@ def reply( code=executable_code, ) + if len(exec_result.variables) > 0: + post_proxy.update_attachment( + json.dumps(exec_result.variables), + AttachmentType.session_variables, + ) + code_output = self.executor.format_code_output( exec_result, with_code=False, diff --git a/taskweaver/memory/attachment.py b/taskweaver/memory/attachment.py index dc9bcd334..609f9524c 100644 --- a/taskweaver/memory/attachment.py +++ b/taskweaver/memory/attachment.py @@ -44,6 +44,9 @@ class AttachmentType(Enum): invalid_response = "invalid_response" text = "text" + # CodeInterpreter - visible variables snapshot + session_variables = "session_variables" + # shared memory entry shared_memory_entry = "shared_memory_entry" diff --git a/taskweaver/utils/__init__.py b/taskweaver/utils/__init__.py index e82610ae7..ee773773f 100644 --- a/taskweaver/utils/__init__.py +++ b/taskweaver/utils/__init__.py @@ -80,6 +80,18 @@ def json_dump(obj: Any, fp: Any): json.dump(obj, fp, cls=EnhancedJSONEncoder) +def pretty_repr(val: Any, limit: int = 200) -> str: + try: + rendered = repr(val) + except Exception: + rendered = "" + + if len(rendered) > limit: + omitted = len(rendered) - limit + return f"{rendered[:limit]}...omitted {omitted} chars..." + return rendered + + def generate_md5_hash(content: str) -> str: from hashlib import md5 From 1d1a8ea7853fcd3bcfeba65639115062755b674b Mon Sep 17 00:00:00 2001 From: liqun Date: Mon, 26 Jan 2026 10:39:34 +0800 Subject: [PATCH 02/10] init deep --- AGENTS.md | 19 +++-- docs/design/code-interpreter-vars.md | 14 ++-- taskweaver/ces/AGENTS.md | 73 ++++++++++++++++++ taskweaver/code_interpreter/AGENTS.md | 83 ++++++++++++++++++++ taskweaver/ext_role/AGENTS.md | 104 +++++++++++++++++++++++++ taskweaver/llm/AGENTS.md | 62 +++++++++++++++ taskweaver/memory/AGENTS.md | 106 ++++++++++++++++++++++++++ 7 files changed, 450 insertions(+), 11 deletions(-) create mode 100644 taskweaver/ces/AGENTS.md create mode 100644 taskweaver/code_interpreter/AGENTS.md create mode 100644 taskweaver/ext_role/AGENTS.md create mode 100644 taskweaver/llm/AGENTS.md create mode 100644 taskweaver/memory/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 8171f6e70..227e2edbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,16 @@ # AGENTS.md - TaskWeaver Development Guide +**Generated:** 2026-01-26 | **Commit:** 7c2888e | **Branch:** liqun/add_variables_to_code_generator + This document provides guidance for AI coding agents working on the TaskWeaver codebase. +## Subdirectory Knowledge Bases +- [`taskweaver/llm/AGENTS.md`](taskweaver/llm/AGENTS.md) - LLM provider abstractions +- [`taskweaver/ces/AGENTS.md`](taskweaver/ces/AGENTS.md) - Code execution service (Jupyter kernels) +- [`taskweaver/code_interpreter/AGENTS.md`](taskweaver/code_interpreter/AGENTS.md) - Code interpreter role variants +- [`taskweaver/memory/AGENTS.md`](taskweaver/memory/AGENTS.md) - Memory data model (Post/Round/Conversation) +- [`taskweaver/ext_role/AGENTS.md`](taskweaver/ext_role/AGENTS.md) - Extended role definitions + ## Project Overview TaskWeaver is a **code-first agent framework** for data analytics tasks. It uses Python 3.10+ and follows a modular architecture with dependency injection (using `injector`). @@ -190,15 +199,15 @@ config = { ``` taskweaver/ ├── app/ # Application entry points and session management -├── ces/ # Code execution service +├── ces/ # Code execution service (see ces/AGENTS.md) ├── chat/ # Chat interfaces (console, web) ├── cli/ # CLI implementation -├── code_interpreter/ # Code generation and interpretation +├── code_interpreter/ # Code generation and interpretation (see code_interpreter/AGENTS.md) ├── config/ # Configuration management -├── ext_role/ # Extended roles (web_search, image_reader, etc.) -├── llm/ # LLM integrations (OpenAI, Anthropic, etc.) +├── ext_role/ # Extended roles (see ext_role/AGENTS.md) +├── llm/ # LLM integrations (see llm/AGENTS.md) ├── logging/ # Logging and telemetry -├── memory/ # Conversation memory and attachments +├── memory/ # Conversation memory (see memory/AGENTS.md) ├── misc/ # Utilities and component registry ├── module/ # Core modules (tracing, events) ├── planner/ # Planning logic diff --git a/docs/design/code-interpreter-vars.md b/docs/design/code-interpreter-vars.md index 18f4cd53e..5ebf847df 100644 --- a/docs/design/code-interpreter-vars.md +++ b/docs/design/code-interpreter-vars.md @@ -19,27 +19,29 @@ The code interpreter generates Python in a persistent kernel but the prompt does - Filtering rules: - Skip names starting with `_`. - Skip builtins and common libs: `__builtins__`, `In`, `Out`, `get_ipython`, `exit`, `quit`, `pd`, `np`, `plt`. - - Skip modules and any defined functions (only keep data-bearing variables). - - For other values, store `(name, repr(value))`, truncated to 500 chars and fall back to `` on repr errors. + - Skip modules, functions, and plugin instances (data-only snapshot). + - For values, store `(name, repr(value))`, truncated to 500 chars; numpy arrays get shape/dtype-aware pretty repr; fall back to `` on repr errors. - Store the snapshot on `ExecutorPluginContext.latest_variables`. 2) **Return variables with execution result** - - `Executor.get_post_execution_state` now includes `variables` (list of `(name, repr)` tuples). + - `Executor.get_post_execution_state` includes `variables` (list of `(name, repr)` tuples). - `Environment._parse_exec_result` copies these into `ExecutionResult.variables` (added to dataclass). 3) **Surface variables to user and prompt** - `CodeExecutor.format_code_output` renders available variables when there is no explicit result/output, using `pretty_repr` to keep lines concise. - - `CodeInterpreter.reply` attaches a new `session_variables` attachment (JSON list of tuples) when variables are present. - - `CodeGenerator.compose_conversation` ignores this attachment in assistant-message rendering but includes it in feedback via `format_code_feedback`, adding an “Available Variables” section for the model’s context. + - `CodeInterpreter.reply` attaches a `session_variables` attachment (JSON list of tuples) when variables are present. + - Prompt threading strategy: + - Assistant turns: `session_variables` is **ignored** via `ignored_types` to avoid polluting assistant history with execution-state metadata. + - Final user turn of the latest round: the attachment is decoded and appended as a “Currently available variables” block so the model can reuse state in the next code generation. 4) **Attachment type** - Added `AttachmentType.session_variables` to carry the variable snapshot per execution. ## Open Items / Next Steps -- Wire the variables directly into the final user turn’s prompt text (e.g., under a “Currently available variables” block) to make reuse even clearer. - Revisit filtering to ensure we skip large data/DF previews (could add size/type caps). - Validate end-to-end with unit tests for: variable capture, attachment propagation, prompt inclusion, and formatting. + ## Files Touched - `taskweaver/ces/runtime/context.py` — collect and store visible variables. - `taskweaver/ces/runtime/executor.py` — expose variables in post-execution state. diff --git a/taskweaver/ces/AGENTS.md b/taskweaver/ces/AGENTS.md new file mode 100644 index 000000000..dc06fb748 --- /dev/null +++ b/taskweaver/ces/AGENTS.md @@ -0,0 +1,73 @@ +# Code Execution Service (CES) - AGENTS.md + +Jupyter kernel-based code execution with local and container modes. + +## Structure + +``` +ces/ +├── environment.py # Environment class - kernel management (~700 lines) +├── common.py # ExecutionResult, ExecutionArtifact, EnvPlugin dataclasses +├── client.py # CES client for remote execution +├── __init__.py # Exports +├── kernel/ # Custom Jupyter kernel implementation +│ └── ext.py # IPython magic commands for TaskWeaver +├── runtime/ # Runtime support files +└── manager/ # Session/kernel lifecycle management +``` + +## Key Classes + +### Environment (environment.py) +Main orchestrator for code execution: +- `EnvMode.Local`: Direct kernel via `MultiKernelManager` +- `EnvMode.Container`: Docker container with mounted volumes + +### ExecutionResult (common.py) +```python +@dataclass +class ExecutionResult: + execution_id: str + code: str + is_success: bool + error: str + output: str + stdout: List[str] + stderr: List[str] + log: List[str] + artifact: List[ExecutionArtifact] + variables: Dict[str, str] # Session variables from execution +``` + +## Execution Flow + +1. `start_session()` - Creates kernel (local or container) +2. `load_plugin()` - Registers plugins in kernel namespace +3. `execute_code()` - Runs code, captures output/artifacts +4. `stop_session()` - Cleanup kernel/container + +## Container Mode Specifics + +- Image: `taskweavercontainers/taskweaver-executor:latest` +- Ports: 5 ports mapped (shell, iopub, stdin, hb, control) +- Volumes: `ces/` and `cwd/` mounted read-write +- Connection file written to `ces/conn-{session}-{kernel}.json` + +## Custom Kernel Magics (kernel/ext.py) + +```python +%_taskweaver_session_init {session_id} +%_taskweaver_plugin_register {name} +%_taskweaver_plugin_load {name} +%_taskweaver_exec_pre_check {index} {exec_id} +%_taskweaver_exec_post_check {index} {exec_id} +%%_taskweaver_update_session_var +``` + +## Adding Plugin Support + +Plugins are loaded via magic commands: +1. `_taskweaver_plugin_register` - Registers plugin class +2. `_taskweaver_plugin_load` - Instantiates with config + +Session variables updated via `%%_taskweaver_update_session_var` magic. diff --git a/taskweaver/code_interpreter/AGENTS.md b/taskweaver/code_interpreter/AGENTS.md new file mode 100644 index 000000000..cb545e7ab --- /dev/null +++ b/taskweaver/code_interpreter/AGENTS.md @@ -0,0 +1,83 @@ +# Code Interpreter - AGENTS.md + +Code generation and execution roles with multiple variants. + +## Structure + +``` +code_interpreter/ +├── interpreter.py # Interpreter ABC (update_session_variables) +├── code_executor.py # CodeExecutor - bridges to CES +├── code_verification.py # AST-based code validation +├── plugin_selection.py # Plugin selection logic +├── code_interpreter/ # Full code interpreter +│ ├── code_interpreter.py # CodeInterpreter role (~320 lines) +│ ├── code_generator.py # LLM-based code generation +│ └── code_interpreter.role.yaml +├── code_interpreter_cli_only/ # CLI-only variant (no plugins) +│ ├── code_interpreter_cli_only.py +│ ├── code_generator_cli_only.py +│ └── code_interpreter_cli_only.role.yaml +└── code_interpreter_plugin_only/ # Plugin-only variant (no free-form code) + ├── code_interpreter_plugin_only.py + ├── code_generator_plugin_only.py + └── code_interpreter_plugin_only.role.yaml +``` + +## Role Variants + +| Variant | Plugins | Free-form Code | Use Case | +|---------|---------|----------------|----------| +| `code_interpreter` | Yes | Yes | Full capability | +| `code_interpreter_cli_only` | No | Yes | Restricted to CLI | +| `code_interpreter_plugin_only` | Yes | No | Only plugin calls | + +## Key Classes + +### CodeInterpreter (Role, Interpreter) +- Orchestrates: CodeGenerator -> verification -> CodeExecutor +- Retry logic: up to `max_retry_count` (default 3) on failures +- Config: `CodeInterpreterConfig` (verification settings, blocked functions) + +### CodeGenerator +- LLM-based code generation from conversation context +- Outputs: code + explanation via PostEventProxy +- Configurable verification: allowed_modules, blocked_functions + +### CodeExecutor +- Wraps CES Environment +- Plugin loading from PluginRegistry +- Session variable management + +## Code Verification (code_verification.py) + +AST-based checks: +- `allowed_modules`: Whitelist of importable modules +- `blocked_functions`: Blacklist (default: `eval`, `exec`, `open`, etc.) + +```python +code_verify_errors = code_snippet_verification( + code, + code_verification_on=True, + allowed_modules=["pandas", "numpy"], + blocked_functions=["eval", "exec"], +) +``` + +## Role YAML Schema + +```yaml +module: taskweaver.code_interpreter.code_interpreter.code_interpreter.CodeInterpreter +alias: CodeInterpreter # Used in message routing +intro: | + - Description of capabilities + - {plugin_description} placeholder for dynamic plugin list +``` + +## Execution Flow + +1. `reply()` called with Memory context +2. CodeGenerator produces code via LLM +3. Code verification (if enabled) +4. CodeExecutor runs code in CES kernel +5. Results formatted back to Post diff --git a/taskweaver/ext_role/AGENTS.md b/taskweaver/ext_role/AGENTS.md new file mode 100644 index 000000000..3768efa54 --- /dev/null +++ b/taskweaver/ext_role/AGENTS.md @@ -0,0 +1,104 @@ +# Extended Roles - AGENTS.md + +Custom role definitions extending TaskWeaver capabilities. + +## Structure + +``` +ext_role/ +├── __init__.py +├── web_search/ # Web search role +│ ├── web_search.py +│ └── web_search.role.yaml +├── web_explorer/ # Browser automation role +│ ├── web_explorer.py +│ ├── driver.py # Selenium/Playwright driver +│ ├── planner.py # Web action planning +│ └── web_explorer.role.yaml +├── image_reader/ # Image analysis role +│ ├── image_reader.py +│ └── image_reader.role.yaml +├── document_retriever/ # Document RAG role +│ ├── document_retriever.py +│ └── document_retriever.role.yaml +├── recepta/ # Custom tool orchestration +│ ├── recepta.py +│ └── recepta.role.yaml +└── echo/ # Debug/test echo role + ├── echo.py + └── echo.role.yaml +``` + +## Role YAML Schema + +Each role requires a `.role.yaml` file: + +```yaml +module: taskweaver.ext_role.{name}.{name}.{ClassName} +alias: {DisplayName} # Used in message routing +intro: | + - Capability description line 1 + - Capability description line 2 +``` + +## Creating a New Extended Role + +1. Create directory: `ext_role/my_role/` +2. Create `my_role.py`: +```python +from taskweaver.role import Role +from taskweaver.role.role import RoleConfig, RoleEntry + +class MyRoleConfig(RoleConfig): + def _configure(self): + # Config inherits from parent dir name + self.custom_setting = self._get_str("custom", "default") + +class MyRole(Role): + @inject + def __init__( + self, + config: MyRoleConfig, + logger: TelemetryLogger, + tracing: Tracing, + event_emitter: SessionEventEmitter, + role_entry: RoleEntry, + ): + super().__init__(config, logger, tracing, event_emitter, role_entry) + + def reply(self, memory: Memory, **kwargs) -> Post: + # Implement role logic + post_proxy = self.event_emitter.create_post_proxy(self.alias) + # ... process and respond + return post_proxy.end() +``` + +3. Create `my_role.role.yaml`: +```yaml +module: taskweaver.ext_role.my_role.my_role.MyRole +alias: MyRole +intro: | + - This role does X + - It can handle Y +``` + +4. Enable in session config: +```json +{ + "session.roles": ["planner", "code_interpreter", "my_role"] +} +``` + +## Role Discovery + +RoleRegistry scans: +- `ext_role/*/\*.role.yaml` +- `code_interpreter/*/\*.role.yaml` + +Registry refreshes every 5 minutes (TTL). + +## Naming Convention + +- Directory name = role name = config namespace +- Class name = PascalCase of directory name +- Alias used for `send_to`/`send_from` in Posts diff --git a/taskweaver/llm/AGENTS.md b/taskweaver/llm/AGENTS.md new file mode 100644 index 000000000..f631a8165 --- /dev/null +++ b/taskweaver/llm/AGENTS.md @@ -0,0 +1,62 @@ +# LLM Module - AGENTS.md + +Provider abstraction layer for LLM and embedding services. + +## Structure + +``` +llm/ +├── base.py # Abstract base: CompletionService, EmbeddingService, LLMModuleConfig +├── util.py # ChatMessageType, format_chat_message, token counting +├── openai.py # OpenAI/Azure OpenAI provider (largest file ~430 lines) +├── anthropic.py # Anthropic Claude provider +├── google_genai.py # Google Generative AI provider +├── ollama.py # Ollama local LLM provider +├── qwen.py # Alibaba Qwen provider +├── zhipuai.py # ZhipuAI provider +├── groq.py # Groq provider +├── azure_ml.py # Azure ML endpoints +├── sentence_transformer.py # Local embedding via sentence_transformers +├── mock.py # Mock provider for testing +├── placeholder.py # Placeholder when no LLM configured +└── __init__.py # LLMApi facade class +``` + +## Key Patterns + +### Provider Registration +New providers must: +1. Subclass `CompletionService` or `EmbeddingService` from `base.py` +2. Implement `chat_completion()` generator or `get_embeddings()` +3. Register in `__init__.py` LLMApi class's provider mapping + +### Config Hierarchy +```python +class MyProviderConfig(LLMServiceConfig): + def _configure(self) -> None: + self._set_name("my_provider") # creates llm.my_provider.* namespace + self.custom_setting = self._get_str("custom_setting", "default") +``` + +### ChatMessageType +```python +ChatMessageType = TypedDict("ChatMessageType", { + "role": str, # "system", "user", "assistant" + "content": str, + "name": NotRequired[str], +}) +``` + +## Adding a New LLM Provider + +1. Create `taskweaver/llm/newprovider.py` +2. Implement config class extending `LLMServiceConfig` +3. Implement service class extending `CompletionService` +4. Add to `_completion_service_map` in `__init__.py` +5. Document in `llm.api_type` config options + +## Common Gotchas + +- `response_format` options: `"json_object"`, `"text"`, `"json_schema"` +- Streaming: All providers return `Generator[ChatMessageType, None, None]` +- OpenAI file is largest (~430 lines) - handles both OpenAI and Azure OpenAI diff --git a/taskweaver/memory/AGENTS.md b/taskweaver/memory/AGENTS.md new file mode 100644 index 000000000..355379f46 --- /dev/null +++ b/taskweaver/memory/AGENTS.md @@ -0,0 +1,106 @@ +# Memory Module - AGENTS.md + +Conversation history data model: Post, Round, Conversation, Attachment. + +## Structure + +``` +memory/ +├── memory.py # Memory class - session conversation store +├── conversation.py # Conversation - list of Rounds +├── round.py # Round - single user query + responses +├── post.py # Post - single message between roles +├── attachment.py # Attachment - typed data on Posts +├── type_vars.py # Type aliases (RoleName, etc.) +├── experience.py # Experience storage and retrieval +├── compression.py # RoundCompressor for prompt compression +├── plugin.py # PluginModule for DI +├── shared_memory_entry.py # SharedMemoryEntry for cross-role data +└── utils.py # Utility functions +``` + +## Data Model Hierarchy + +``` +Memory +└── Conversation + └── Round[] + ├── user_query: str + ├── state: "created" | "finished" | "failed" + └── Post[] + ├── send_from: str (role name) + ├── send_to: str (role name) + ├── message: str + └── Attachment[] + ├── type: AttachmentType + ├── content: str + └── extra: Any +``` + +## Key Classes + +### Post (post.py) +```python +@dataclass +class Post: + id: str + send_from: str + send_to: str + message: str + attachment_list: List[Attachment] + + @staticmethod + def create(message: str, send_from: str, send_to: str) -> Post +``` + +### AttachmentType (attachment.py) +```python +class AttachmentType(str, Enum): + # Planning + init_plan = "init_plan" + plan = "plan" + current_plan_step = "current_plan_step" + + # Code execution + reply_content = "reply_content" # Generated code + verification = "verification" + execution_status = "execution_status" + execution_result = "execution_result" + + # Control flow + revise_message = "revise_message" + invalid_response = "invalid_response" + + # Shared state + shared_memory_entry = "shared_memory_entry" + session_variables = "session_variables" +``` + +### SharedMemoryEntry (shared_memory_entry.py) +Cross-role communication: +```python +@dataclass +class SharedMemoryEntry: + type: str # "plan", "experience_sub_path", etc. + scope: str # "round" or "conversation" + content: str +``` + +## Memory Patterns + +### Role-specific Round Filtering +```python +# Get rounds relevant to a specific role +rounds = memory.get_role_rounds(role="Planner", include_failure_rounds=False) +``` + +### Shared Memory Queries +```python +# Get shared entries by type +entries = memory.get_shared_memory_entries(entry_type="plan") +``` + +## Serialization + +All dataclasses support `to_dict()` and `from_dict()` for YAML/JSON persistence. +Experience saving: `memory.save_experience(exp_dir, thin_mode=True)` From 7bade76feda06cb9df97a2dff3b4e43cb9704978 Mon Sep 17 00:00:00 2001 From: liqun Date: Mon, 26 Jan 2026 11:00:25 +0800 Subject: [PATCH 03/10] remove chainlit --- .gitignore | 3 + docs/design/threading_model.md | 330 ++++++++++++++++++ playground/UI/.chainlit/config.toml | 84 ----- playground/UI/app.py | 462 ------------------------- playground/UI/chainlit.md | 16 - playground/UI/public/favicon.ico | Bin 67646 -> 0 bytes playground/UI/public/logo_dark.png | Bin 65635 -> 0 bytes playground/UI/public/logo_light.png | Bin 65635 -> 0 bytes playground/UI/public/style_v1.css | 192 ---------- project/taskweaver_config.json | 5 - project/taskweaver_config.json.example | 6 + taskweaver/cli/web.py | 54 --- website/docs/quickstart.md | 5 +- website/docs/usage/webui.md | 36 -- website/sidebars.js | 1 - website/static/img/ui_screenshot_1.png | Bin 95022 -> 0 bytes website/static/img/ui_screenshot_2.png | Bin 73462 -> 0 bytes 17 files changed, 342 insertions(+), 852 deletions(-) create mode 100644 docs/design/threading_model.md delete mode 100644 playground/UI/.chainlit/config.toml delete mode 100644 playground/UI/app.py delete mode 100644 playground/UI/chainlit.md delete mode 100644 playground/UI/public/favicon.ico delete mode 100644 playground/UI/public/logo_dark.png delete mode 100644 playground/UI/public/logo_light.png delete mode 100644 playground/UI/public/style_v1.css delete mode 100644 project/taskweaver_config.json create mode 100644 project/taskweaver_config.json.example delete mode 100644 taskweaver/cli/web.py delete mode 100644 website/docs/usage/webui.md delete mode 100644 website/static/img/ui_screenshot_1.png delete mode 100644 website/static/img/ui_screenshot_2.png diff --git a/.gitignore b/.gitignore index b67ecefe9..e1b579559 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ workspace/* set_env.sh sample_case_results.csv +# Project config (use taskweaver_config.json.example as template) +project/taskweaver_config.json + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/docs/design/threading_model.md b/docs/design/threading_model.md new file mode 100644 index 000000000..23c3e207d --- /dev/null +++ b/docs/design/threading_model.md @@ -0,0 +1,330 @@ +# TaskWeaver Threading Model - Design Document + +**Generated:** 2026-01-26 | **Author:** AI Agent | **Status:** Documentation + +## Overview + +TaskWeaver employs a **dual-thread architecture** for console-based user interaction. When a user submits a request, the main process spawns two threads: + +1. **Execution Thread** - Runs the actual task processing (LLM calls, code execution) +2. **Animation Thread** - Handles real-time console display with status updates and animations + +These threads communicate via an **event-driven architecture** using a shared update queue protected by threading primitives. + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Main Thread │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ TaskWeaverChatApp.run() │ │ +│ │ └── _handle_message(input) │ │ +│ │ └── TaskWeaverRoundUpdater.handle_message() │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────┴───────────────┐ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │ +│ │ Execution Thread (t_ex) │ │ Animation Thread (t_ui) │ │ +│ │ │ │ │ │ +│ │ session.send_message() │ │ _animate_thread() │ │ +│ │ ├── Planner.reply() │ │ ├── Process updates │ │ +│ │ ├── CodeInterpreter │ │ ├── Render status bar │ │ +│ │ │ .reply() │ │ ├── Display messages │ │ +│ │ └── Event emission ──────┼───┼──► └── Animate spinner │ │ +│ │ │ │ │ │ +│ └─────────────────────────────┘ └─────────────────────────────┘ │ +│ │ │ │ +│ └───────────────┬───────────────┘ │ +│ ▼ │ +│ exit_event.set() │ +│ Main thread joins │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Key Components + +### 1. TaskWeaverRoundUpdater (chat/console/chat.py) + +The central coordinator that manages both threads and handles events. + +```python +class TaskWeaverRoundUpdater(SessionEventHandlerBase): + def __init__(self): + self.exit_event = threading.Event() # Signals completion + self.update_cond = threading.Condition() # Wakes animation thread + self.lock = threading.Lock() # Protects shared state + + self.pending_updates: List[Tuple[str, str]] = [] # Event queue + self.result: Optional[str] = None +``` + +### 2. Thread Spawning (handle_message) + +```python +def handle_message(self, session, message, files): + def execution_thread(): + try: + round = session.send_message(message, event_handler=self, files=files) + last_post = round.post_list[-1] + if last_post.send_to == "User": + self.result = last_post.message + finally: + self.exit_event.set() + with self.update_cond: + self.update_cond.notify_all() + + t_ui = threading.Thread(target=lambda: self._animate_thread(), daemon=True) + t_ex = threading.Thread(target=execution_thread, daemon=True) + + t_ui.start() + t_ex.start() + + # Main thread waits for completion + while True: + self.exit_event.wait(0.1) + if self.exit_event.is_set(): + break +``` + +### 3. Event Flow + +``` +┌──────────────────┐ emit() ┌──────────────────┐ handle() ┌──────────────────┐ +│ PostEventProxy │─────────────►│ SessionEventEmitter│────────────►│TaskWeaverRoundUpdater│ +└──────────────────┘ └──────────────────┘ └──────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ pending_updates │ + │ (queue) │ + └──────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ Animation Thread │ + │ (consumer) │ + └──────────────────┘ +``` + +## Event Types + +### Session Events (EventScope.session) +| Event | Description | +|-------|-------------| +| `session_start` | Session initialization | +| `session_end` | Session termination | +| `session_new_round` | New conversation round | + +### Round Events (EventScope.round) +| Event | Description | +|-------|-------------| +| `round_start` | User query processing begins | +| `round_end` | User query processing complete | +| `round_error` | Error during processing | +| `round_new_post` | New message in round | + +### Post Events (EventScope.post) +| Event | Description | +|-------|-------------| +| `post_start` | Role begins generating response | +| `post_end` | Role finished response | +| `post_error` | Error in post generation | +| `post_status_update` | Status text change ("generating code", "executing") | +| `post_send_to_update` | Recipient change | +| `post_message_update` | Message content streaming | +| `post_attachment_update` | Attachment (code, plan, etc.) update | + +## Animation Thread Details + +The animation thread (`_animate_thread`) runs a continuous loop: + +```python +def _animate_thread(self): + while True: + clear_line() + + # Process all pending updates atomically + with self.lock: + for action, opt in self.pending_updates: + if action == "start_post": + # Display role header: ╭───< Planner > + elif action == "end_post": + # Display completion: ╰──● sending to User + elif action == "attachment_start": + # Begin attachment display + elif action == "attachment_add": + # Append to current attachment + elif action == "attachment_end": + # Finalize and render attachment + elif action == "status_update": + # Update status message + self.pending_updates.clear() + + if self.exit_event.is_set(): + break + + # Display animated status bar + # " TaskWeaver ▶ [Planner] generating code <=💡=>" + display_status_bar(role, status, get_ani_frame(counter)) + + # Rate limit animation (~30Hz visual, 5Hz animation) + with self.update_cond: + self.update_cond.wait(0.2) +``` + +### Console Output Format + +``` + ╭───< Planner > + ├─► [init_plan] Analyze the user request... + ├─► [plan] 1. Parse input data... + ├──● The task involves processing the CSV file... + ╰──● sending message to CodeInterpreter + + ╭───< CodeInterpreter > + ├─► [reply_content] import pandas as pd... + ├─► [verification] CORRECT + ├─► [execution_status] SUCCESS + ├──● [Execution result]... + ╰──● sending message to Planner +``` + +## Synchronization Primitives + +| Primitive | Purpose | +|-----------|---------| +| `threading.Lock` | Protects `pending_updates` queue during read/write | +| `threading.Event` | Signals execution completion (`exit_event`) | +| `threading.Condition` | Wakes animation thread when updates available | + +### Critical Sections + +1. **Event emission** (execution thread writes): +```python +with self.lock: + self.pending_updates.append(("status_update", msg)) +``` + +2. **Update processing** (animation thread reads): +```python +with self.lock: + for action, opt in self.pending_updates: + # Process... + self.pending_updates.clear() +``` + +## Additional Threading: Stream Smoother + +The LLM module (`llm/__init__.py`) uses a separate threading model for **LLM response streaming**: + +```python +def _stream_smoother(self, stream_init): + """ + Smooths LLM token streaming for better UX. + + Problem: LLM tokens arrive in bursts (fast) then pauses (slow). + Solution: Buffer tokens and emit at normalized rate. + """ + buffer_content = "" + finished = False + + def base_stream_puller(): + # Thread: Pull from LLM, add to buffer + for msg in stream_init(): + with update_lock: + buffer_content += msg["content"] + + thread = threading.Thread(target=base_stream_puller) + thread.start() + + # Main: Drain buffer at smoothed rate + while not finished: + yield normalized_chunk() +``` + +## Thread Lifecycle + +``` +Time ──────────────────────────────────────────────────────────► + +Main ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░████████████ + spawn wait(exit_event) join threads + +Execution ░░░░████████████████████████████████████████░░░░░░░░░░ + send_message() → Planner → CodeInterpreter → result + +Animation ░░░░██░██░██░██░██░██░██░██░██░██░██░██░██░░░░░░░░░░░░ + render → sleep(0.2) → render → sleep → render + +Legend: █ = active, ░ = waiting/idle +``` + +## Error Handling + +### Keyboard Interrupt +```python +try: + while True: + self.exit_event.wait(0.1) + if self.exit_event.is_set(): + break +except KeyboardInterrupt: + error_message("Interrupted by user") + exit(1) # Immediate exit - session state unknown +``` + +### Execution Errors +```python +def execution_thread(): + try: + round = session.send_message(...) + except Exception as e: + self.response.append("Error") + raise e + finally: + self.exit_event.set() # Always signal completion +``` + +## Design Rationale + +### Why Two Threads? + +1. **Non-blocking UI**: LLM calls and code execution can take seconds/minutes. Animation thread keeps UI responsive. + +2. **Real-time feedback**: Users see incremental progress (streaming text, status updates) rather than waiting for complete response. + +3. **Clean separation**: Execution logic doesn't need to know about display; display doesn't block execution. + +### Why Event Queue? + +1. **Decoupling**: Event emitters (Planner, CodeInterpreter) don't know about console display. + +2. **Batching**: Multiple rapid events can be processed in single animation frame. + +3. **Thread safety**: Queue with lock is simpler than direct UI updates from multiple threads. + +## Comparison with Other Modes + +| Mode | Threading | Display | +|------|-----------|---------| +| Console (`chat_taskweaver`) | 2 threads (exec + anim) | Real-time animated | +| Web/API | Single thread per request | WebSocket/SSE streaming | +| Programmatic | Caller's thread | Event callbacks | + +## File References + +| File | Component | +|------|-----------| +| `chat/console/chat.py` | `TaskWeaverRoundUpdater`, `_animate_thread` | +| `module/event_emitter.py` | `SessionEventEmitter`, `TaskWeaverEvent`, `PostEventProxy` | +| `llm/__init__.py` | `_stream_smoother` (LLM streaming) | +| `ces/manager/defer.py` | `deferred_var` (kernel warm-up) | + +## Summary + +TaskWeaver's console interface uses a clean dual-thread model: +- **Execution thread**: Runs the agent pipeline (Planner → CodeInterpreter → result) +- **Animation thread**: Consumes events and renders real-time console output + +Communication happens via an event queue (`pending_updates`) protected by a lock, with a condition variable for efficient wake-up. This design provides responsive UI feedback during long-running AI operations while maintaining clean separation of concerns. diff --git a/playground/UI/.chainlit/config.toml b/playground/UI/.chainlit/config.toml deleted file mode 100644 index 874bf08c5..000000000 --- a/playground/UI/.chainlit/config.toml +++ /dev/null @@ -1,84 +0,0 @@ -[project] -# Whether to enable telemetry (default: true). No personal data is collected. -enable_telemetry = false - -# List of environment variables to be provided by each user to use the app. -user_env = [] - -# Duration (in seconds) during which the session is saved when the connection is lost -session_timeout = 3600 - -# Enable third parties caching (e.g LangChain cache) -cache = false - -# Follow symlink for asset mount (see https://github.com/Chainlit/chainlit/issues/317) -# follow_symlink = true - -[features] -# Show the prompt playground -prompt_playground = true - -# Process and display HTML in messages. This can be a security risk (see https://stackoverflow.com/questions/19603097/why-is-it-dangerous-to-render-user-generated-html-or-javascript) -unsafe_allow_html = true - -# Process and display mathematical expressions. This can clash with "$" characters in messages. -latex = false - -# Authorize users to upload files with messages -spontaneous_file_upload.enabled = true - -# Allows user to use speech to text -[features.speech_to_text] - enabled = false - # See all languages here https://github.com/JamesBrill/react-speech-recognition/blob/HEAD/docs/API.md#language-string - # language = "en-US" - -[UI] -# Name of the app and chatbot. -name = "TaskWeaver" - -# Show the readme while the conversation is empty. -show_readme_as_default = true - -# Description of the app and chatbot. This is used for HTML tags. -# description = "Chat with TaskWeaver" - -# Large size content are by default collapsed for a cleaner ui -default_collapse_content = false - -# The default value for the expand messages settings. -default_expand_messages = true - -# Hide the chain of thought details from the user in the UI. -hide_cot = false - -# Link to your github repo. This will add a github button in the UI's header. -# github = "https://github.com/microsoft/TaskWeaver" - -# Specify a CSS file that can be used to customize the user interface. -# The CSS file can be served from the public directory or via an external link. -custom_css = "/public/style_v1.css" - -# Override default MUI light theme. (Check theme.ts) -[UI.theme.light] - #background = "#FAFAFA" - #paper = "#FFFFFF" - - [UI.theme.light.primary] - #main = "#F80061" - #dark = "#980039" - #light = "#FFE7EB" - -# Override default MUI dark theme. (Check theme.ts) -[UI.theme.dark] - #background = "#FAFAFA" - #paper = "#FFFFFF" - - [UI.theme.dark.primary] - #main = "#F80061" - #dark = "#980039" - #light = "#FFE7EB" - - -[meta] -generated_by = "0.7.700" diff --git a/playground/UI/app.py b/playground/UI/app.py deleted file mode 100644 index 6a081c1a3..000000000 --- a/playground/UI/app.py +++ /dev/null @@ -1,462 +0,0 @@ -import atexit -import functools -import os -import re -import sys -from typing import Any, Dict, List, Optional, Tuple, Union - -import requests - -# change current directory to the directory of this file for loading resources -os.chdir(os.path.dirname(__file__)) - -try: - import chainlit as cl - - print( - "If UI is not started, please go to the folder playground/UI and run `chainlit run app.py` to start the UI", - ) -except Exception: - raise Exception( - "Package chainlit is required for using UI. Please install it manually by running: " - "`pip install chainlit` and then run `chainlit run app.py`", - ) - -repo_path = os.path.join(os.path.dirname(__file__), "../../") -sys.path.append(repo_path) -from taskweaver.app.app import TaskWeaverApp -from taskweaver.memory.attachment import AttachmentType -from taskweaver.memory.type_vars import RoleName -from taskweaver.module.event_emitter import PostEventType, RoundEventType, SessionEventHandlerBase -from taskweaver.session.session import Session - -project_path = os.path.join(repo_path, "project") -app = TaskWeaverApp(app_dir=project_path, use_local_uri=True) -atexit.register(app.stop) -app_session_dict: Dict[str, Session] = {} - - -def elem(name: str, cls: str = "", attr: Dict[str, str] = {}, **attr_dic: str): - all_attr = {**attr, **attr_dic} - if cls: - all_attr.update({"class": cls}) - - attr_str = "" - if len(all_attr) > 0: - attr_str += "".join(f' {k}="{v}"' for k, v in all_attr.items()) - - def inner(*children: str): - children_str = "".join(children) - return f"<{name}{attr_str}>{children_str}" - - return inner - - -def txt(content: str, br: bool = True): - content = content.replace("<", "<").replace(">", ">") - if br: - content = content.replace("\n", "
") - else: - content = content.replace("\n", " ") - return content - - -div = functools.partial(elem, "div") -span = functools.partial(elem, "span") -blinking_cursor = span("tw-end-cursor")() - - -def file_display(files: List[Tuple[str, str]], session_cwd_path: str): - elements: List[cl.Element] = [] - for file_name, file_path in files: - # if image, no need to display as another file - if file_path.endswith((".png", ".jpg", ".jpeg", ".gif")): - image = cl.Image( - name=file_path, - display="inline", - path=file_path if os.path.isabs(file_path) else os.path.join(session_cwd_path, file_path), - size="large", - ) - elements.append(image) - elif file_path.endswith((".mp3", ".wav", ".flac")): - audio = cl.Audio( - name="converted_speech", - display="inline", - path=file_path if os.path.isabs(file_path) else os.path.join(session_cwd_path, file_path), - ) - elements.append(audio) - else: - if file_path.endswith(".csv"): - import pandas as pd - - data = ( - pd.read_csv(file_path) - if os.path.isabs(file_path) - else pd.read_csv(os.path.join(session_cwd_path, file_path)) - ) - row_count = len(data) - table = cl.Text( - name=file_path, - content=f"There are {row_count} in the data. The top {min(row_count, 5)} rows are:\n" - + data.head(n=5).to_markdown(), - display="inline", - ) - elements.append(table) - else: - print(f"Unsupported file type: {file_name} for inline display.") - # download files from plugin context - file = cl.File( - name=file_name, - display="inline", - path=file_path if os.path.isabs(file_path) else os.path.join(session_cwd_path, file_path), - ) - elements.append(file) - return elements - - -def is_link_clickable(url: str): - if url: - try: - response = requests.get(url) - # If the response status code is 200, the link is clickable - return response.status_code == 200 - except requests.exceptions.RequestException: - return False - else: - return False - - -class ChainLitMessageUpdater(SessionEventHandlerBase): - def __init__(self, root_step: cl.Step): - self.root_step = root_step - self.reset_cur_step() - self.suppress_blinking_cursor() - - def reset_cur_step(self): - self.cur_step: Optional[cl.Step] = None - self.cur_attachment_list: List[Tuple[str, AttachmentType, str, bool]] = [] - self.cur_post_status: str = "Updating" - self.cur_send_to: RoleName = "Unknown" - self.cur_message: str = "" - self.cur_message_is_end: bool = False - self.cur_message_sent: bool = False - - def suppress_blinking_cursor(self): - cl.run_sync(self.root_step.stream_token("")) - if self.cur_step is not None: - cl.run_sync(self.cur_step.stream_token("")) - - def handle_round( - self, - type: RoundEventType, - msg: str, - extra: Any, - round_id: str, - **kwargs: Any, - ): - if type == RoundEventType.round_error: - self.root_step.is_error = True - self.root_step.output = msg - cl.run_sync(self.root_step.update()) - - def handle_post( - self, - type: PostEventType, - msg: str, - extra: Any, - post_id: str, - round_id: str, - **kwargs: Any, - ): - if type == PostEventType.post_start: - self.reset_cur_step() - self.cur_step = cl.Step(name=extra["role"], show_input=True, root=False) - cl.run_sync(self.cur_step.__aenter__()) - elif type == PostEventType.post_end: - assert self.cur_step is not None - content = self.format_post_body(True) - cl.run_sync(self.cur_step.stream_token(content, True)) - cl.run_sync(self.cur_step.__aexit__(None, None, None)) # type: ignore - self.reset_cur_step() - elif type == PostEventType.post_error: - pass - elif type == PostEventType.post_attachment_update: - assert self.cur_step is not None, "cur_step should not be None" - id: str = extra["id"] - a_type: AttachmentType = extra["type"] - is_end: bool = extra["is_end"] - # a_extra: Any = extra["extra"] - if len(self.cur_attachment_list) == 0 or id != self.cur_attachment_list[-1][0]: - self.cur_attachment_list.append((id, a_type, msg, is_end)) - - else: - prev_msg = self.cur_attachment_list[-1][2] - self.cur_attachment_list[-1] = (id, a_type, prev_msg + msg, is_end) - - elif type == PostEventType.post_send_to_update: - self.cur_send_to = extra["role"] - elif type == PostEventType.post_message_update: - self.cur_message += msg - if extra["is_end"]: - self.cur_message_is_end = True - elif type == PostEventType.post_status_update: - self.cur_post_status = msg - - if self.cur_step is not None: - content = self.format_post_body(False) - cl.run_sync(self.cur_step.stream_token(content, True)) - if self.cur_message_is_end and not self.cur_message_sent: - self.cur_message_sent = True - self.cur_step.elements = [ - *(self.cur_step.elements or []), - cl.Text( - content=self.cur_message, - display="inline", - ), - ] - cl.run_sync(self.cur_step.update()) - self.suppress_blinking_cursor() - - def get_message_from_user(self, prompt: str, timeout: int = 120) -> Optional[str]: - ask_user_msg = cl.AskUserMessage(content=prompt, author=" ", timeout=timeout) - res = cl.run_sync(ask_user_msg.send()) - cl.run_sync(ask_user_msg.remove()) - if res is not None: - res_msg = cl.Message.from_dict(res) - msg_txt = res_msg.content - cl.run_sync(res_msg.remove()) - return msg_txt - return None - - def get_confirm_from_user( - self, - prompt: str, - actions: List[Union[Tuple[str, str], str]], - timeout: int = 120, - ) -> Optional[str]: - cl_actions: List[cl.Action] = [] - for arg_action in actions: - if isinstance(arg_action, str): - cl_actions.append(cl.Action(name=arg_action, value=arg_action)) - else: - name, value = arg_action - cl_actions.append(cl.Action(name=name, value=value)) - ask_user_msg = cl.AskActionMessage(content=prompt, actions=cl_actions, author=" ", timeout=timeout) - res = cl.run_sync(ask_user_msg.send()) - cl.run_sync(ask_user_msg.remove()) - if res is not None: - for action in cl_actions: - if action.value == res["value"]: - return action.value - return None - - def format_post_body(self, is_end: bool) -> str: - content_chunks: List[str] = [] - - for attachment in self.cur_attachment_list: - a_type = attachment[1] - - # skip artifact paths always - if a_type in [AttachmentType.artifact_paths]: - continue - - # skip Python in final result - if is_end and a_type in [AttachmentType.reply_content]: - continue - - content_chunks.append(self.format_attachment(attachment)) - - if self.cur_message != "": - if self.cur_send_to == "Unknown": - content_chunks.append("**Message**:") - else: - content_chunks.append(f"**Message To {self.cur_send_to}**:") - - if not self.cur_message_sent: - content_chunks.append( - self.format_message(self.cur_message, self.cur_message_is_end), - ) - - if not is_end: - content_chunks.append( - div("tw-status")( - span("tw-status-updating")( - elem("svg", viewBox="22 22 44 44")(elem("circle")()), - ), - span("tw-status-msg")(txt(self.cur_post_status + "...")), - ), - ) - - return "\n\n".join(content_chunks) - - def format_attachment( - self, - attachment: Tuple[str, AttachmentType, str, bool], - ) -> str: - id, a_type, msg, is_end = attachment - header = div("tw-atta-header")( - div("tw-atta-key")( - " ".join([item.capitalize() for item in a_type.value.split("_")]), - ), - div("tw-atta-id")(id), - ) - atta_cnt: List[str] = [] - - if a_type in [AttachmentType.plan, AttachmentType.init_plan]: - items: List[str] = [] - lines = msg.split("\n") - for idx, row in enumerate(lines): - item = row - if "." in row and row.split(".")[0].isdigit(): - item = row.split(".", 1)[1].strip() - items.append( - div("tw-plan-item")( - div("tw-plan-idx")(str(idx + 1)), - div("tw-plan-cnt")( - txt(item), - blinking_cursor if not is_end and idx == len(lines) - 1 else "", - ), - ), - ) - atta_cnt.append(div("tw-plan")(*items)) - elif a_type in [AttachmentType.execution_result]: - atta_cnt.append( - elem("pre", "tw-execution-result")( - elem("code")(txt(msg)), - ), - ) - elif a_type in [AttachmentType.reply_content]: - atta_cnt.append( - elem("pre", "tw-python", {"data-lang": "python"})( - elem("code", "language-python")(txt(msg, br=False)), - ), - ) - else: - atta_cnt.append(txt(msg)) - if not is_end: - atta_cnt.append(blinking_cursor) - - return div("tw-atta")( - header, - div("tw-atta-cnt")(*atta_cnt), - ) - - def format_message(self, message: str, is_end: bool) -> str: - content = txt(message, br=False) - begin_regex = re.compile(r"^```(\w*)$\n", re.MULTILINE) - end_regex = re.compile(r"^```$\n?", re.MULTILINE) - - if not is_end: - end_tag = " " + blinking_cursor - else: - end_tag = "" - - while True: - start_label = begin_regex.search(content) - if not start_label: - break - start_pos = content.index(start_label[0]) - lang_tag = start_label[1] - content = "".join( - [ - content[:start_pos], - f'
',
-                    content[start_pos + len(start_label[0]) :],
-                ],
-            )
-
-            end_pos = end_regex.search(content)
-            if not end_pos:
-                content += end_tag + "
" - end_tag = "" - break - end_pos_pos = content.index(end_pos[0]) - content = f"{content[:end_pos_pos]}{content[end_pos_pos + len(end_pos[0]):]}" - - content += end_tag - return content - - -@cl.on_chat_start -async def start(): - user_session_id = cl.user_session.get("id") - app_session_dict[user_session_id] = app.get_session() - print("Starting new session") - - -@cl.on_chat_end -async def end(): - user_session_id = cl.user_session.get("id") - app_session = app_session_dict[user_session_id] - print(f"Stopping session {app_session.session_id}") - app_session.stop() - app_session_dict.pop(user_session_id) - - -@cl.on_message -async def main(message: cl.Message): - user_session_id = cl.user_session.get("id") # type: ignore - session: Session = app_session_dict[user_session_id] # type: ignore - session_cwd_path = session.execution_cwd - - # display loader before sending message - async with cl.Step(name="", show_input=True, root=True) as root_step: - response_round = await cl.make_async(session.send_message)( - message.content, - files=[ - { - "name": element.name if element.name else "file", - "path": element.path, - } - for element in message.elements - if element.type == "file" or element.type == "image" - ], - event_handler=ChainLitMessageUpdater(root_step), - ) - - artifact_paths = [ - p - for p in response_round.post_list - for a in p.attachment_list - if a.type == AttachmentType.artifact_paths - for p in a.content - ] - - for post in [p for p in response_round.post_list if p.send_to == "User"]: - files: List[Tuple[str, str]] = [] - if len(artifact_paths) > 0: - for file_path in artifact_paths: - # if path is image or csv (the top 5 rows), display it - file_name = os.path.basename(file_path) - files.append((file_name, file_path)) - - # Extract the file path from the message and display it - user_msg_content = post.message - pattern = r"(!?)\[(.*?)\]\((.*?)\)" - matches = re.findall(pattern, user_msg_content) - for match in matches: - img_prefix, file_name, file_path = match - if "://" in file_path: - if not is_link_clickable(file_path): - user_msg_content = user_msg_content.replace( - f"{img_prefix}[{file_name}]({file_path})", - file_name, - ) - continue - files.append((file_name, file_path)) - user_msg_content = user_msg_content.replace( - f"{img_prefix}[{file_name}]({file_path})", - file_name, - ) - elements = file_display(files, session_cwd_path) - await cl.Message( - author="TaskWeaver", - content=f"{user_msg_content}", - elements=elements if len(elements) > 0 else None, - ).send() - - -if __name__ == "__main__": - from chainlit.cli import run_chainlit - - run_chainlit(__file__) diff --git a/playground/UI/chainlit.md b/playground/UI/chainlit.md deleted file mode 100644 index 4eae7eafc..000000000 --- a/playground/UI/chainlit.md +++ /dev/null @@ -1,16 +0,0 @@ -# Welcome to *TaskWeaver* ! - -*Hi there, User! 👋 We're excited to have you on board.* - -TaskWeaver is a code-first agent framework for seamlessly planning and executing data analytics tasks. This innovative framework interprets user requests through coded snippets and efficiently coordinates a variety of plugins in the form of functions to execute data analytics tasks. It supports key Features like: rich data structure, customized algorithms, incorporating domain-specific knowledge, stateful conversation, code verification, easy to use, debug and extend. - -## Useful Links 🔗 - -- **Quick Start:** Quick start TaskWeaver with [README](https://github.com/microsoft/TaskWeaver?tab=readme-ov-file#-quick-start) ✨ -- **Advanced Configurations:** Get started with our [TaskWeaver Documents](https://microsoft.github.io/TaskWeaver/) 📚 -- **Technical Report:** Check out our [TaskWeaver Report](https://export.arxiv.org/abs/2311.17541) for more details! 📖 -- **Discord Channel:** Join the TaskWeaver [Discord Channel](https://discord.gg/Z56MXmZgMb) for discussions 💬 - -We can't wait to see what you create with TaskWeaver! - -**Start the Conversation!** diff --git a/playground/UI/public/favicon.ico b/playground/UI/public/favicon.ico deleted file mode 100644 index be759b29740d763d47bc4a2f11ed76b55cf0a3a3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 67646 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 HPN)AL!HJ`O diff --git a/playground/UI/public/logo_dark.png b/playground/UI/public/logo_dark.png deleted file mode 100644 index 5e4eaa009328352b823b61c930aa72ae5dba3491..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65635 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 diff --git a/playground/UI/public/logo_light.png b/playground/UI/public/logo_light.png deleted file mode 100644 index 5e4eaa009328352b823b61c930aa72ae5dba3491..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65635 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 diff --git a/playground/UI/public/style_v1.css b/playground/UI/public/style_v1.css deleted file mode 100644 index 65758fb8b..000000000 --- a/playground/UI/public/style_v1.css +++ /dev/null @@ -1,192 +0,0 @@ -img[alt='logo'] { - max-height: 40px !important; - display: inline-block; -} - -.post { - border: 1px solid #ccc; - padding: 10px; - margin-bottom: 10px; - max-width: 800px; -} -.markdown-body { - padding-left: 10px; - padding-right: 10px; -} - -.tw-atta { - display: block; - border: solid 2px #aaa5; - border-radius: 10px; - background-color: #fff4; - overflow: hidden; - box-shadow: 0 0 10px 2px #ccc5; - margin: 10px 0; -} - -.tw-atta-header { - height: 20px; - border-bottom: solid 2px #aaa5; - padding: 5px 10px; - background-color: #5090ff55; - font-weight: 500; - display: flex; -} - -.tw-atta-key { - flex: 1; -} -.tw-atta-id { - opacity: 0.3; - font-size: 0.8em; -} - -.tw-atta-cnt { - padding: 10px 20px; -} - -.markdown-body .tw-plan { - position: relative; -} -div.markdown-body div.tw-plan::before { - content: ''; - display: block; - width: 4px; - height: calc(100% + 20px); - position: absolute; - background-color: #eee; - top: -10px; - left: 15px; -} - -div.markdown-body div.tw-plan-item { - display: flex; -} - -.markdown-body div.tw-plan-idx { - flex: 0 0 20px; - position: relative; - width: 20px; - height: 20px; - border-radius: 12px; - text-align: center; - line-height: 20px; - border: solid 2px #a0c0ff; - background-color: #c0e0ff; - margin: 5px !important; - margin-top: 5px; - font-weight: 500; - color: #555; -} - -.markdown-body div.tw-plan-cnt { - margin: 5px 10px; - margin-top: 5px; -} - -.markdown-body .tw-status { - display: inline-block; - padding: 5px 10px; - border-radius: 3px; - font-size: 14px; - line-height: 20px; - font-weight: 500; - color: #555; - white-space: nowrap; - background-color: #eee; - min-width: 120px; - margin: 10px; -} - -.markdown-body .tw-status-msg { - margin: 10px; - padding: 0; - height: 20px; -} - -/* Updater spinner (adopted from MUI for align with Chainlit) */ -@keyframes tw-updating-status-ani-dash { - 0% { - stroke-dasharray: 1px, 200px; - stroke-dashoffset: 0; - } - - 50% { - stroke-dasharray: 100px, 200px; - stroke-dashoffset: -15px; - } - - 100% { - stroke-dasharray: 100px, 200px; - stroke-dashoffset: -125px; - } -} - -@keyframes tw-updating-status-ani-circle { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} - -.markdown-body .tw-status-updating { - width: 20px; - height: 20px; - display: inline-block; - color: #aaa; - animation: 1.4s linear 0s infinite normal none running - tw-updating-status-ani-circle; -} - -.markdown-body .tw-status-updating svg { - display: block; -} - -.markdown-body .tw-status-updating svg circle { - stroke: currentColor; - stroke-dasharray: 80px, 200px; - stroke-dashoffset: 0; - stroke-width: 4; - fill: none; - r: 20; - cx: 44; - cy: 44; - animation: tw-updating-status-ani-dash 1.4s ease-in-out infinite; -} - -@keyframes tw-blinking-dot { - 0% { - opacity: 0.2; - } - - 20% { - opacity: 1; - } - - 100% { - opacity: 0.2; - } -} - -span.tw-end-cursor { - content: ''; - display: inline-flex; - width: 10px; - border-radius: 5px; - margin-left: 10px; -} - -span.tw-end-cursor::after { - content: ''; - position: relative; - display: block; - width: 10px; - height: 10px; - border-radius: 5px; - background-color: #a0c0ff; - margin: auto; - animation: tw-blinking-dot 0.7s ease-in-out infinite; -} diff --git a/project/taskweaver_config.json b/project/taskweaver_config.json deleted file mode 100644 index 7a17b3d89..000000000 --- a/project/taskweaver_config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "llm.api_base": "https://api.openai.com/v1", - "llm.api_key": "", - "llm.model": "gpt-4-1106-preview" -} \ No newline at end of file diff --git a/project/taskweaver_config.json.example b/project/taskweaver_config.json.example new file mode 100644 index 000000000..24d30a9b6 --- /dev/null +++ b/project/taskweaver_config.json.example @@ -0,0 +1,6 @@ +{ + "llm.api_type": "openai", + "llm.api_base": "https://api.openai.com/v1", + "llm.api_key": "YOUR_API_KEY", + "llm.model": "gpt-4" +} diff --git a/taskweaver/cli/web.py b/taskweaver/cli/web.py deleted file mode 100644 index b05b8e4c7..000000000 --- a/taskweaver/cli/web.py +++ /dev/null @@ -1,54 +0,0 @@ -import click - -from taskweaver.cli.util import require_workspace - - -@click.command() -@require_workspace() -@click.option( - "--host", - "-h", - default="localhost", - help="Host to run TaskWeaver web server", - type=str, - show_default=True, -) -@click.option("--port", "-p", default=8080, help="Port to run TaskWeaver web server", type=int, show_default=True) -@click.option( - "--debug", - "-d", - is_flag=True, - default=False, - help="Run TaskWeaver web server in debug mode", - show_default=True, -) -@click.option( - "--open/--no-open", - "-o/-n", - is_flag=True, - default=True, - help="Open TaskWeaver web server in browser", - show_default=True, -) -def web(host: str, port: int, debug: bool, open: bool): - """Start TaskWeaver web server""" - - from taskweaver.chat.web import start_web_service - - if not debug: - # debug mode will restart app iteratively, skip the plugin listing - # display_enabled_examples_plugins() - pass - - def post_app_start(): - if open: - click.secho("launching web browser...", fg="green") - open_url = f"http://{'localhost' if host == '0.0.0.0' else host}:{port}" - click.launch(open_url) - - start_web_service( - host, - port, - is_debug=debug, - post_app_start=post_app_start if open else None, - ) diff --git a/website/docs/quickstart.md b/website/docs/quickstart.md index e5b89ad8c..5ab018828 100644 --- a/website/docs/quickstart.md +++ b/website/docs/quickstart.md @@ -37,7 +37,9 @@ A project directory typically contains the following files and folders: ## OpenAI Configuration Before running TaskWeaver, you need to provide your OpenAI API key and other necessary information. -You can do this by editing the `taskweaver_config.json` file. +You can do this by creating a `taskweaver_config.json` file in your project directory. +A template file `taskweaver_config.json.example` is provided - copy it to `taskweaver_config.json` and fill in your credentials. + If you are using Azure OpenAI, you need to set the following parameters in the `taskweaver_config.json` file: ### Azure OpenAI ```json @@ -83,6 +85,5 @@ Human: ___ ``` There are other ways to start TaskWeaver: -- [A Chainlit UI interface](./usage/webui.md): TaskWeaver provides an experimental web-based interface to interact with the system. - [A Library](./usage/library.md): You can also use TaskWeaver as a library in your Python code. - [The all-in-one Docker image](./usage/docker.md): We provide a Docker image that contains all the dependencies to run TaskWeaver. diff --git a/website/docs/usage/webui.md b/website/docs/usage/webui.md deleted file mode 100644 index d1c31daaa..000000000 --- a/website/docs/usage/webui.md +++ /dev/null @@ -1,36 +0,0 @@ -# Web UI - -:::warning -Please note that this Web UI is a playground for development and testing purposes only. -Be cautious when running the Web UI, as anyone can access it if the port is open to the public. -If you want to deploy a Web UI for production, you need to address security concerns, such as authentication and authorization, -making sure the server is secure. -::: - -Follow the instruction in [Quick Start](../quickstart.md) to clone the repository and fill in the necessary configurations. - -Install the `chainlit` package by `pip install -U "chainlit<1.1.300"` if you don't have it in your environment. - -:::note -Chainlit has a major update in version 1.1.300 that may cause compatibility issues. -Please make sure you have the correct version installed. -::: - -Start the service by running the following command. - - -```bash -# assume you are in the TaskWeaver folder -cd playground/UI/ -# make sure you are in playground/UI/ folder -chainlit run app.py -``` - -Open the browser with http://localhost:8000 if it doesn't open automatically. -:::info -We now support uploading files using the Web UI. -::: -Below are some screenshots of the Web UI: -![TaskWeaver UI Screenshot 1](../../static/img/ui_screenshot_1.png) -![TaskWeaver UI Screenshot 2](../../static/img/ui_screenshot_2.png) - diff --git a/website/sidebars.js b/website/sidebars.js index 577af653c..1d60a040c 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -60,7 +60,6 @@ const sidebars = { collapsed: false, items: [ 'usage/cmd', - 'usage/webui', 'usage/library', 'usage/docker', ], diff --git a/website/static/img/ui_screenshot_1.png b/website/static/img/ui_screenshot_1.png deleted file mode 100644 index 0726254b762936bdf44c32ab22a5c8072b100475..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 95022 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�pj9O|8tT=pR}`|+f3Dpt2E*f*<&EK#*@YI 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®dH5 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 From ff4ce8321597d12ff4d257bc1eec435d758b60a9 Mon Sep 17 00:00:00 2001 From: liqun Date: Mon, 26 Jan 2026 14:17:15 +0800 Subject: [PATCH 04/10] add user confirmation before code execution --- docs/design/threading_model.md | 332 ++++++++++++++++-- taskweaver/chat/console/chat.py | 116 +++++- .../code_interpreter/code_interpreter.py | 22 ++ taskweaver/module/event_emitter.py | 98 ++++++ taskweaver/planner/planner_prompt.yaml | 6 +- .../test_animation_pause_handshake.py | 230 ++++++++++++ .../test_event_emitter_confirmation.py | 189 ++++++++++ 7 files changed, 961 insertions(+), 32 deletions(-) create mode 100644 tests/unit_tests/test_animation_pause_handshake.py create mode 100644 tests/unit_tests/test_event_emitter_confirmation.py diff --git a/docs/design/threading_model.md b/docs/design/threading_model.md index 23c3e207d..4048c968f 100644 --- a/docs/design/threading_model.md +++ b/docs/design/threading_model.md @@ -20,6 +20,8 @@ These threads communicate via an **event-driven architecture** using a shared up │ │ TaskWeaverChatApp.run() │ │ │ │ └── _handle_message(input) │ │ │ │ └── TaskWeaverRoundUpdater.handle_message() │ │ +│ │ ├── Polls for confirmation requests │ │ +│ │ └── Handles user confirmation input │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ │ ┌───────────────┴───────────────┐ │ @@ -31,7 +33,11 @@ These threads communicate via an **event-driven architecture** using a shared up │ │ ├── Planner.reply() │ │ ├── Process updates │ │ │ │ ├── CodeInterpreter │ │ ├── Render status bar │ │ │ │ │ .reply() │ │ ├── Display messages │ │ -│ │ └── Event emission ──────┼───┼──► └── Animate spinner │ │ +│ │ │ ├── generate code │ │ ├── Animate spinner │ │ +│ │ │ ├── verify code │ │ └── Pause on confirm │ │ +│ │ │ ├── WAIT confirm ◄─┼───┼──────────────────────────── │ │ +│ │ │ └── execute code │ │ │ │ +│ │ └── Event emission ──────┼───┼──► pending_updates queue │ │ │ │ │ │ │ │ │ └─────────────────────────────┘ └─────────────────────────────┘ │ │ │ │ │ @@ -56,6 +62,11 @@ class TaskWeaverRoundUpdater(SessionEventHandlerBase): self.lock = threading.Lock() # Protects shared state self.pending_updates: List[Tuple[str, str]] = [] # Event queue + + # Pause/resume handshake for animation thread + self.pause_animation = threading.Event() # Main requests pause + self.animation_paused = threading.Event() # Animation acknowledges pause + self.result: Optional[str] = None ``` @@ -137,11 +148,26 @@ def handle_message(self, session, message, files): ## Animation Thread Details -The animation thread (`_animate_thread`) runs a continuous loop: +The animation thread (`_animate_thread`) runs a continuous loop with confirmation-aware synchronization: ```python def _animate_thread(self): while True: + # Check if pause is requested FIRST, before any output + if self.pause_animation.is_set(): + # Signal that animation has paused + self.animation_paused.set() + # Wait until pause is lifted + while self.pause_animation.is_set(): + if self.exit_event.is_set(): + break + with self.update_cond: + self.update_cond.wait(0.1) + continue + + # Animation is running, clear the paused signal + self.animation_paused.clear() + clear_line() # Process all pending updates atomically @@ -151,24 +177,20 @@ def _animate_thread(self): # Display role header: ╭───< Planner > elif action == "end_post": # Display completion: ╰──● sending to User - elif action == "attachment_start": - # Begin attachment display - elif action == "attachment_add": - # Append to current attachment - elif action == "attachment_end": - # Finalize and render attachment - elif action == "status_update": - # Update status message + # ... other actions self.pending_updates.clear() if self.exit_event.is_set(): break + # Check again before printing status line + if self.pause_animation.is_set(): + continue + # Display animated status bar - # " TaskWeaver ▶ [Planner] generating code <=💡=>" display_status_bar(role, status, get_ani_frame(counter)) - # Rate limit animation (~30Hz visual, 5Hz animation) + # Rate limit animation with self.update_cond: self.update_cond.wait(0.2) ``` @@ -194,9 +216,11 @@ def _animate_thread(self): | Primitive | Purpose | |-----------|---------| -| `threading.Lock` | Protects `pending_updates` queue during read/write | -| `threading.Event` | Signals execution completion (`exit_event`) | -| `threading.Condition` | Wakes animation thread when updates available | +| `threading.Lock` (`lock`) | Protects `pending_updates` queue during read/write | +| `threading.Event` (`exit_event`) | Signals execution completion | +| `threading.Event` (`pause_animation`) | Main requests animation to pause | +| `threading.Event` (`animation_paused`) | Animation acknowledges it has paused | +| `threading.Condition` (`update_cond`) | Wakes animation thread when updates available | ### Critical Sections @@ -246,16 +270,36 @@ def _stream_smoother(self, stream_init): ## Thread Lifecycle ``` -Time ──────────────────────────────────────────────────────────► +Time ──────────────────────────────────────────────────────────────────────────────────► + +Main ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░████████████░░░░░░░░░░░░░░░░░████████████████ + spawn wait(exit_event) confirm? wait confirm? join + +Execution ░░░░██████████████████████████████░░░░░░░░░░░░██████████████████░░░░░░░░░░░░░░ + Planner → CodeInterpreter WAIT(cond) continue→result + +Animation ░░░░██░██░██░██░██░██░██░██░██░██░░░░░░░░░░░░░██░██░██░██░██░██░░░░░░░░░░░░░░░ + render → sleep → render PAUSED resume → render + +Legend: █ = active, ░ = waiting/idle +``` + +### With Confirmation Flow -Main ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░████████████ - spawn wait(exit_event) join threads +``` +Time ──────────────────────────────────────────────────────────────────► -Execution ░░░░████████████████████████████████████████░░░░░░░░░░ - send_message() → Planner → CodeInterpreter → result +Main ████░░░░░░░░░░░░░░░░████████████████░░░░░░░░░░░░████████████████ + spawn polling show code polling join + get input -Animation ░░░░██░██░██░██░██░██░██░██░██░██░██░██░██░░░░░░░░░░░░ - render → sleep(0.2) → render → sleep → render +Execution ░░░░██████████████████░░░░░░░░░░░░░██████████████░░░░░░░░░░░░░░ + generate code BLOCKED execute code done + request confirm (waiting) (if approved) + +Animation ░░░░██░██░██░██░██░░░░░░░░░░░░░░░░░░██░██░██░██░░░░░░░░░░░░░░░░ + animate STOPPED resume + (no output) animation Legend: █ = active, ░ = waiting/idle ``` @@ -312,12 +356,243 @@ def execution_thread(): | Web/API | Single thread per request | WebSocket/SSE streaming | | Programmatic | Caller's thread | Event callbacks | +## Animation Pause Handshake Pattern + +The console UI uses a simple, extensible handshake pattern to temporarily pause animation output when exclusive console access is needed. + +### The Pattern + +```python +# Two events form the handshake +pause_animation = threading.Event() # Request: "please pause" +animation_paused = threading.Event() # Acknowledgment: "I have paused" +``` + +### How It Works + +**Requester (main thread or any code needing exclusive console):** +```python +# 1. Request pause +self.pause_animation.set() + +# 2. Wait for acknowledgment +self.animation_paused.wait() + +# 3. Safe to use console exclusively +do_exclusive_console_work() + +# 4. Release +self.animation_paused.clear() +self.pause_animation.clear() +``` + +**Animation thread (responder):** +```python +while True: + # Check at START of loop, before any output + if self.pause_animation.is_set(): + self.animation_paused.set() # Acknowledge + while self.pause_animation.is_set(): # Wait for release + wait() + continue + + self.animation_paused.clear() # Signal "I'm running" + do_animation_output() +``` + +### Timing Diagram + +``` +Main Thread Animation Thread +─────────── ──────────────── + [Loop start] + pause_animation? → NO + Clear animation_paused + Print status line... +Set pause_animation ─────────────────────────────────────────► +Wait for animation_paused [Loop start] + │ pause_animation? → YES + │ Set animation_paused + ◄────────────────────────────────────────────────────┘ +(wait returns) Wait in loop... +Show prompt, get input (no output) +Clear animation_paused +Clear pause_animation ───────────────────────────────────────► + [Loop continues] + pause_animation? → NO + Resume output +``` + +### Why This Pattern + +1. **Simple**: Two events, clear semantics +2. **Safe**: Animation always checks before output +3. **Extensible**: Any code can use it, not just confirmation +4. **No locks needed**: Handshake guarantees ordering + +### Current Usage + +| Feature | Uses Handshake | +|---------|----------------| +| Code confirmation prompt | ✓ | +| (Future) Interactive debugging | Can use same pattern | +| (Future) Multi-line input | Can use same pattern | + +### Adding New Features + +To add a new feature that needs exclusive console access: + +```python +def my_new_feature(self): + # Pause animation + self.pause_animation.set() + self.animation_paused.wait(timeout=5.0) + + try: + # Your exclusive console work here + result = get_user_input() + finally: + # Always release, even on error + self.animation_paused.clear() + self.pause_animation.clear() + + return result +``` + +--- + +## Code Execution Confirmation + +When `code_interpreter.require_confirmation` is enabled, TaskWeaver will pause before executing generated code to get user confirmation. + +### Confirmation Flow + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Execution Thread│ │ Main Thread │ │Animation Thread │ +└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + │ generate code │ │ [Loop start] + │ verify code │ │ Check confirmation_active + │ │ │ → false, continue + │ │ │ Clear animation_stopped + │ │ │ Acquire output_lock + │ │ │ Print status line + │ │ │ Release output_lock + │ │ │ │ + │ set _confirmation_event│ │ [Loop start] + │ emit confirmation_req │ │ Check confirmation_active + │ WAIT on _confirm_cond ─┼────────────────────────┼─→ true! + │ (blocked) │ │ Set animation_stopped ◄──┐ + │ │ detect confirmation │ Wait in loop │ + │ │ set confirmation_active│ │ + │ │ wait animation_stopped ─────────────────────────────┘ + │ │ acquire output_lock │ (cannot acquire lock) + │ │ clear line, show code │ │ + │ │ get user input [y/N] │ │ (waiting) + │ │ show result │ │ + │ │ release output_lock │ │ + │ │ clear animation_stopped│ │ + │ │ clear confirmation_active │ + │ │ set _confirmation_result │ + │ │ notify _confirm_cond │ │ + │ ◄──────────────────────┼────────────────────────┤ │ + │ (unblocked) │ │ [Loop continues] + │ read & clear result │ │ confirmation_active=false + │ │ │ Resume normal animation + │ if approved: │ │ │ + │ execute code │ │ │ + │ else: │ │ │ + │ cancel execution │ │ │ + ▼ ▼ ▼ +``` + +### Configuration + +Enable confirmation in your `taskweaver_config.json`: + +```json +{ + "code_interpreter.require_confirmation": true +} +``` + +### Synchronization Primitives + +The confirmation system uses a two-level synchronization approach to prevent race conditions where the animation thread could overwrite user input: + +#### Event Emitter Primitives (in `SessionEventEmitter`) + +| Primitive | Set By | Cleared By | Purpose | +|-----------|--------|------------|---------| +| `_confirmation_event` | Execution thread | Main thread | Signals that confirmation is pending | +| `_confirmation_cond` | Main thread | - | Condition variable for blocking/waking execution thread | +| `_confirmation_result` | Main thread | Execution thread | Stores user's decision (True/False) | + +#### Console UI Primitives (in `TaskWeaverRoundUpdater`) + +| Primitive | Set By | Cleared By | Purpose | +|-----------|--------|------------|---------| +| `pause_animation` | Main thread | Main thread | Requests animation to pause | +| `animation_paused` | Animation thread | Main thread | Confirms animation has paused | + +### Thread Responsibilities + +**Execution Thread:** +- Sets `_confirmation_event` when code needs confirmation +- Emits `post_confirmation_request` event +- Waits on `_confirmation_cond` until user responds +- Reads and clears `_confirmation_result` + +**Main Thread (`_handle_confirmation`):** +1. Sets `pause_animation` to signal animation thread +2. Waits for `animation_paused` to ensure animation has paused +3. Displays code and gets user input (safe from interference) +4. Clears `animation_paused` and `pause_animation` +5. Sets `_confirmation_result` and notifies `_confirmation_cond` + +**Animation Thread (`_animate_thread`):** +1. Checks `pause_animation` at **start of each loop iteration** +2. If set: sets `animation_paused` and waits in a loop until `pause_animation` cleared +3. If not set: clears `animation_paused` and proceeds with output + +### Why This Design Prevents Race Conditions + +The handshake guarantees animation has stopped before main thread shows the prompt: + +``` +Animation Thread Main Thread +──────────────── ─────────── +[Loop iteration] +Check pause_animation → false +Clear animation_paused +Print status line + Set pause_animation + Wait for animation_paused +[Next loop iteration] +Check pause_animation → TRUE +Set animation_paused ───────────────────► animation_paused.wait() returns +Wait in loop Show prompt, get input +(no output) Clear animation_paused + Clear pause_animation +Check pause_animation → false +Resume normal operation +``` + +### Key Implementation Points + +1. **Early check**: Animation thread checks `pause_animation` at the **very start** of its loop, before any output operations +2. **Explicit acknowledgment**: `animation_paused` confirms animation has paused (not just signaled to pause) +3. **Clean display**: Main thread clears any leftover animation before showing code +4. **Extensible**: Any code needing exclusive console access can use the same handshake + ## File References | File | Component | |------|-----------| -| `chat/console/chat.py` | `TaskWeaverRoundUpdater`, `_animate_thread` | -| `module/event_emitter.py` | `SessionEventEmitter`, `TaskWeaverEvent`, `PostEventProxy` | +| `chat/console/chat.py` | `TaskWeaverRoundUpdater`, `_animate_thread`, `_handle_confirmation` | +| `module/event_emitter.py` | `SessionEventEmitter`, `TaskWeaverEvent`, `PostEventProxy`, `ConfirmationHandler` | +| `code_interpreter/code_interpreter/code_interpreter.py` | `CodeInterpreter.reply()` (confirmation request) | | `llm/__init__.py` | `_stream_smoother` (LLM streaming) | | `ces/manager/defer.py` | `deferred_var` (kernel warm-up) | @@ -328,3 +603,12 @@ TaskWeaver's console interface uses a clean dual-thread model: - **Animation thread**: Consumes events and renders real-time console output Communication happens via an event queue (`pending_updates`) protected by a lock, with a condition variable for efficient wake-up. This design provides responsive UI feedback during long-running AI operations while maintaining clean separation of concerns. + +### Animation Pause Handshake + +When exclusive console access is needed (e.g., confirmation prompts), use the handshake: +1. Set `pause_animation` → wait for `animation_paused` +2. Do exclusive work +3. Clear `animation_paused` → clear `pause_animation` + +This pattern is simple, safe, and extensible to future features. diff --git a/taskweaver/chat/console/chat.py b/taskweaver/chat/console/chat.py index b7125425b..9cb6937a2 100644 --- a/taskweaver/chat/console/chat.py +++ b/taskweaver/chat/console/chat.py @@ -9,7 +9,13 @@ import click -from taskweaver.module.event_emitter import PostEventType, RoundEventType, SessionEventHandlerBase, SessionEventType +from taskweaver.module.event_emitter import ( + ConfirmationHandler, + PostEventType, + RoundEventType, + SessionEventHandlerBase, + SessionEventType, +) if TYPE_CHECKING: from taskweaver.memory.attachment import AttachmentType @@ -65,7 +71,30 @@ def user_input_message(prompt: str = " Human ") -> str: continue -class TaskWeaverRoundUpdater(SessionEventHandlerBase): +def user_confirmation_input(prompt: str = "Execute code? [y/N]: ") -> str: + import prompt_toolkit + + session = prompt_toolkit.PromptSession[str]( + multiline=False, + ) + + while True: + try: + user_input: str = session.prompt( + prompt_toolkit.formatted_text.FormattedText( + [ + ("bg:ansiyellow fg:ansiblack", " Confirm "), + ("fg:ansiyellow", "▶"), + ("", f" {prompt}"), + ], + ), + ) + return user_input.strip().lower() + except KeyboardInterrupt: + return "n" + + +class TaskWeaverRoundUpdater(SessionEventHandlerBase, ConfirmationHandler): def __init__(self): self.exit_event = threading.Event() self.update_cond = threading.Condition() @@ -74,10 +103,23 @@ def __init__(self): self.last_attachment_id = "" self.pending_updates: List[Tuple[str, str]] = [] + # Handshake pair for pausing animation (e.g., during confirmation prompts) + self.pause_animation = threading.Event() # Main requests pause + self.animation_paused = threading.Event() # Animation acknowledges pause + self.animation_paused.set() # Initially paused (not animating yet) + self.messages: List[Tuple[str, str]] = [] self.response: List[str] = [] self.result: Optional[str] = None + def request_confirmation( + self, + code: str, + round_id: str, + post_id: Optional[str], + ) -> bool: + return True + def handle_session( self, type: SessionEventType, @@ -153,6 +195,8 @@ def handle_message( message: str, files: List[Dict[Literal["name", "path", "content"], str]], ) -> Optional[str]: + session.event_emitter.confirmation_handler = self + def execution_thread(): try: round = session.send_message( @@ -171,7 +215,7 @@ def execution_thread(): with self.update_cond: self.update_cond.notify_all() - t_ui = threading.Thread(target=lambda: self._animate_thread(), daemon=True) + t_ui = threading.Thread(target=lambda: self._animate_thread(session), daemon=True) t_ex = threading.Thread(target=execution_thread, daemon=True) t_ui.start() @@ -179,6 +223,10 @@ def execution_thread(): exit_no_wait: bool = False try: while True: + if session.event_emitter.confirmation_pending: + self._handle_confirmation(session) + continue + self.exit_event.wait(0.1) if self.exit_event.is_set(): break @@ -186,7 +234,6 @@ def execution_thread(): error_message("Interrupted by user") exit_no_wait = True - # keyboard interrupt leave the session in unknown state, exit directly exit(1) finally: self.exit_event.set() @@ -200,8 +247,45 @@ def execution_thread(): return self.result - def _animate_thread(self): - # get terminal width + def _handle_confirmation(self, session: Session) -> None: + from colorama import ansi + + # Signal animation thread to pause + self.pause_animation.set() + + # Wait for animation thread to acknowledge it has paused + self.animation_paused.wait(timeout=5.0) + + # Clear any leftover animation output + print(ansi.clear_line(), end="\r") + + code = session.event_emitter.pending_confirmation_code or "" + + # Display code in a style consistent with the UI + click.secho( + click.style(" ├─► ", fg="blue") + + click.style("[", fg="blue") + + click.style("confirm", fg="bright_cyan") + + click.style("]", fg="blue"), + ) + for line in code.split("\n"): + click.secho(click.style(" │ ", fg="blue") + click.style(line, fg="bright_black")) + + response = user_confirmation_input() + approved = response in ("y", "yes") + + if approved: + click.secho(click.style(" │ ", fg="blue") + click.style("✓ approved", fg="green")) + else: + click.secho(click.style(" │ ", fg="blue") + click.style("✗ cancelled", fg="red")) + + # Allow animation thread to resume + self.animation_paused.clear() + self.pause_animation.clear() + + session.event_emitter.provide_confirmation(approved) + + def _animate_thread(self, session: Session): terminal_column = shutil.get_terminal_size().columns counter = 0 status_msg = "preparing" @@ -306,6 +390,22 @@ def format_status_message(limit: int): last_time = 0 while True: + # Check if we should pause FIRST, before any output + if self.pause_animation.is_set(): + # Signal that animation has paused + self.animation_paused.set() + # Wait until pause is lifted + while self.pause_animation.is_set(): + if self.exit_event.is_set(): + break + with self.update_cond: + self.update_cond.wait(0.1) + # Reset for next iteration + continue + + # Animation is running, clear the paused signal + self.animation_paused.clear() + clear_line() with self.lock: for action, opt in self.pending_updates: @@ -367,6 +467,10 @@ def format_status_message(limit: int): if self.exit_event.is_set(): break + # Check again before printing status line + if self.pause_animation.is_set(): + continue + cur_message_prefix: str = " TaskWeaver " cur_ani_frame = get_ani_frame(counter) cur_message_display_len = ( diff --git a/taskweaver/code_interpreter/code_interpreter/code_interpreter.py b/taskweaver/code_interpreter/code_interpreter/code_interpreter.py index 9770da325..a5adeaa2e 100644 --- a/taskweaver/code_interpreter/code_interpreter/code_interpreter.py +++ b/taskweaver/code_interpreter/code_interpreter/code_interpreter.py @@ -59,6 +59,7 @@ def _configure(self): ) self.code_prefix = self._get_str("code_prefix", "") + self.require_confirmation = self._get_bool("require_confirmation", False) def update_verification( @@ -234,6 +235,27 @@ def reply( elif len(code_verify_errors) == 0: update_verification(post_proxy, "CORRECT", "No error is found.") + if self.config.require_confirmation: + post_proxy.update_status("awaiting confirmation") + self.logger.info("Requesting user confirmation for code execution") + + confirmed = self.event_emitter.request_code_confirmation( + code.content, + post_proxy.post.id, + ) + + if not confirmed: + self.logger.info("Code execution cancelled by user") + self.tracing.set_span_status("OK", "User cancelled code execution.") + update_execution( + post_proxy, + "NONE", + "Code execution was cancelled by user.", + ) + post_proxy.update_message("Code execution was cancelled.") + self.retry_count = 0 + return post_proxy.end() + executable_code = f"{code.content}" full_code_prefix = None if self.config.code_prefix: diff --git a/taskweaver/module/event_emitter.py b/taskweaver/module/event_emitter.py index 7b7708c89..df1629ba9 100644 --- a/taskweaver/module/event_emitter.py +++ b/taskweaver/module/event_emitter.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import threading from contextlib import contextmanager from dataclasses import dataclass from enum import Enum @@ -40,6 +41,8 @@ class PostEventType(Enum): post_send_to_update = "post_send_to_update" post_message_update = "post_message_update" post_attachment_update = "post_attachment_update" + post_confirmation_request = "post_confirmation_request" + post_confirmation_response = "post_confirmation_response" @dataclass @@ -123,6 +126,32 @@ def handle_post( pass +class ConfirmationHandler(abc.ABC): + """Protocol for handling execution confirmations. + + Implementers of this protocol can intercept code execution + and request user confirmation before proceeding. + """ + + @abc.abstractmethod + def request_confirmation( + self, + code: str, + round_id: str, + post_id: Optional[str], + ) -> bool: + """Request confirmation for code execution. + + Args: + code: The code that is about to be executed. + round_id: The current round ID. + post_id: The current post ID (may be None). + + Returns: + True to proceed with execution, False to abort. + """ + + class PostEventProxy: def __init__(self, emitter: SessionEventEmitter, round_id: str, post: Post) -> None: self.emitter = emitter @@ -233,6 +262,75 @@ def __init__(self): self.handlers: List[SessionEventHandler] = [] self.current_round_id: Optional[str] = None + self._confirmation_handler: Optional[ConfirmationHandler] = None + self._confirmation_event = threading.Event() + self._confirmation_cond = threading.Condition() + self._confirmation_result: Optional[bool] = None + self._confirmation_code: Optional[str] = None + self._confirmation_post_id: Optional[str] = None + + @property + def confirmation_handler(self) -> Optional[ConfirmationHandler]: + return self._confirmation_handler + + @confirmation_handler.setter + def confirmation_handler(self, handler: Optional[ConfirmationHandler]): + self._confirmation_handler = handler + + @property + def confirmation_pending(self) -> bool: + return self._confirmation_event.is_set() + + @property + def pending_confirmation_code(self) -> Optional[str]: + return self._confirmation_code + + def request_code_confirmation(self, code: str, post_id: Optional[str] = None) -> bool: + if self._confirmation_handler is None: + return True + + self._confirmation_code = code + self._confirmation_post_id = post_id + self._confirmation_event.set() + + self.emit( + TaskWeaverEvent( + EventScope.post, + PostEventType.post_confirmation_request, + self.current_round_id, + post_id, + code, + extra={"code": code}, + ), + ) + + with self._confirmation_cond: + while self._confirmation_result is None: + self._confirmation_cond.wait() + result = self._confirmation_result + self._confirmation_result = None + self._confirmation_code = None + self._confirmation_post_id = None + + self.emit( + TaskWeaverEvent( + EventScope.post, + PostEventType.post_confirmation_response, + self.current_round_id, + post_id, + "approved" if result else "rejected", + extra={"approved": result}, + ), + ) + + return result + + def provide_confirmation(self, approved: bool): + with self._confirmation_cond: + self._confirmation_result = approved + self._confirmation_event.clear() # Clear immediately so main thread won't see stale True + self._confirmation_cond.notify_all() + def emit(self, event: TaskWeaverEvent): for handler in self.handlers: handler.handle(event) diff --git a/taskweaver/planner/planner_prompt.yaml b/taskweaver/planner/planner_prompt.yaml index 1b87b82c0..b3651dd10 100644 --- a/taskweaver/planner/planner_prompt.yaml +++ b/taskweaver/planner/planner_prompt.yaml @@ -73,6 +73,7 @@ instruction_template: |- + AdditionalInformation: The User's request is incomplete or missing critical information and requires additional information. + SecurityRisks: The User's request contains potential security risks or illegal activities and requires rejection. + TaskFailure: The task fails after few attempts and requires the User's confirmation to proceed. + + UserCancelled: The User has explicitly cancelled the operation (e.g., declined code execution confirmation). Do NOT retry or continue the task - stop immediately and acknowledge the cancellation. ### Examples of planning process @@ -126,6 +127,7 @@ instruction_template: |- - When the request involves loading a file or pulling a table from db, Planner should always set the first subtask to reading the content to understand the structure or schema of the data. - When the request involves text analysis, Planner should always set the first subtask to read and print the text content to understand its content structure. - When the request involves read instructions for task execution, Planner should always update the plan to the steps and sub-steps in the instructions and then follow the updated plan to execute necessary actions. + - When a Worker responds with "Code execution was cancelled by user" or similar cancellation message, Planner must immediately stop the task with stop="UserCancelled" and NOT retry or attempt alternative approaches. ## Planner's response format - Planner must strictly format the response into the following JSON object: @@ -160,10 +162,10 @@ response_json_schema: |- "type": "string", "description": "The current step Planner is executing." }, - "stop": { + "stop": { "type": "string", "description": "The stop reason when the Planner needs to talk to the User. Set it to 'InProcess' if the Planner is not talking to the User.", - "enum": ["InProcess", "Completed", "Clarification", "AdditionalInformation", "SecurityRisks", "TaskFailure"] + "enum": ["InProcess", "Completed", "Clarification", "AdditionalInformation", "SecurityRisks", "TaskFailure", "UserCancelled"] }, "send_to": { "type": "string", diff --git a/tests/unit_tests/test_animation_pause_handshake.py b/tests/unit_tests/test_animation_pause_handshake.py new file mode 100644 index 000000000..8b14b238f --- /dev/null +++ b/tests/unit_tests/test_animation_pause_handshake.py @@ -0,0 +1,230 @@ +"""Tests for the animation pause handshake pattern in TaskWeaverRoundUpdater. + +The handshake uses two events: +- pause_animation: Main thread requests animation to pause +- animation_paused: Animation thread acknowledges it has paused +""" + +import threading +import time + + +class MockAnimationPauseHandshake: + """Minimal implementation of the handshake pattern for testing.""" + + def __init__(self): + self.pause_animation = threading.Event() + self.animation_paused = threading.Event() + self.animation_paused.set() # Initially paused (not running yet) + + self.exit_event = threading.Event() + self.update_cond = threading.Condition() + + # Track what animation thread does + self.output_count = 0 + self.output_log = [] + + def animation_thread_loop(self): + """Simulates the animation thread loop.""" + while not self.exit_event.is_set(): + # Check pause at START of loop + if self.pause_animation.is_set(): + self.animation_paused.set() + while self.pause_animation.is_set(): + if self.exit_event.is_set(): + return + with self.update_cond: + self.update_cond.wait(0.01) + continue + + self.animation_paused.clear() + + # Simulate output + self.output_count += 1 + self.output_log.append(f"output-{self.output_count}") + + with self.update_cond: + self.update_cond.wait(0.05) + + def request_pause(self, timeout: float = 1.0) -> bool: + """Request animation to pause and wait for acknowledgment.""" + self.pause_animation.set() + return self.animation_paused.wait(timeout=timeout) + + def release_pause(self): + """Release the pause and allow animation to resume.""" + self.animation_paused.clear() + self.pause_animation.clear() + + def stop(self): + """Stop the animation thread.""" + self.exit_event.set() + with self.update_cond: + self.update_cond.notify_all() + + +def test_handshake_pauses_animation(): + """Animation should stop outputting when pause is requested.""" + handler = MockAnimationPauseHandshake() + + t = threading.Thread(target=handler.animation_thread_loop, daemon=True) + t.start() + + # Let animation run for a bit + time.sleep(0.1) + count_before_pause = handler.output_count + assert count_before_pause > 0, "Animation should have produced output" + + # Request pause + assert handler.request_pause(), "Pause should be acknowledged" + + # Record count and wait + count_at_pause = handler.output_count + time.sleep(0.1) + count_after_wait = handler.output_count + + # No new output during pause + assert count_after_wait == count_at_pause, "Animation should not output while paused" + + # Release and verify animation resumes + handler.release_pause() + time.sleep(0.1) + count_after_release = handler.output_count + + assert count_after_release > count_at_pause, "Animation should resume after release" + + handler.stop() + t.join(timeout=1) + + +def test_handshake_blocks_until_acknowledged(): + """request_pause should block until animation acknowledges.""" + handler = MockAnimationPauseHandshake() + handler.animation_paused.clear() # Simulate animation running + + acknowledged = threading.Event() + + def delayed_acknowledge(): + time.sleep(0.1) + handler.animation_paused.set() + acknowledged.set() + + t = threading.Thread(target=delayed_acknowledge, daemon=True) + t.start() + + start = time.time() + result = handler.request_pause(timeout=1.0) + elapsed = time.time() - start + + assert result is True + assert elapsed >= 0.1, "Should have waited for acknowledgment" + assert acknowledged.is_set() + + t.join(timeout=1) + + +def test_handshake_timeout(): + """request_pause should timeout if animation doesn't acknowledge.""" + handler = MockAnimationPauseHandshake() + handler.animation_paused.clear() # Simulate animation that never acknowledges + + start = time.time() + result = handler.request_pause(timeout=0.1) + elapsed = time.time() - start + + # Event.wait returns False on timeout + assert result is False + assert elapsed >= 0.1 + + +def test_handshake_multiple_pause_resume_cycles(): + """Handshake should work correctly across multiple pause/resume cycles.""" + handler = MockAnimationPauseHandshake() + + t = threading.Thread(target=handler.animation_thread_loop, daemon=True) + t.start() + + for i in range(3): + # Let animation run + time.sleep(0.05) + handler.output_count + + # Pause + assert handler.request_pause(), f"Cycle {i}: Pause should be acknowledged" + count_at_pause = handler.output_count + time.sleep(0.05) + assert handler.output_count == count_at_pause, f"Cycle {i}: Should not output while paused" + + # Resume + handler.release_pause() + time.sleep(0.05) + assert handler.output_count > count_at_pause, f"Cycle {i}: Should resume after release" + + handler.stop() + t.join(timeout=1) + + +def test_handshake_exit_during_pause(): + """Animation thread should exit cleanly even while paused.""" + handler = MockAnimationPauseHandshake() + + t = threading.Thread(target=handler.animation_thread_loop, daemon=True) + t.start() + + # Pause animation + time.sleep(0.05) + handler.request_pause() + + # Exit while paused + handler.stop() + + # Thread should exit + t.join(timeout=1) + assert not t.is_alive(), "Thread should have exited" + + +def test_handshake_no_output_race(): + """Verify no output occurs between pause request and acknowledgment.""" + handler = MockAnimationPauseHandshake() + + t = threading.Thread(target=handler.animation_thread_loop, daemon=True) + t.start() + + # Let it run + time.sleep(0.05) + + # Pause and immediately record + handler.pause_animation.set() + handler.animation_paused.wait(timeout=1.0) + count_at_ack = handler.output_count + + # Wait and verify no change + time.sleep(0.1) + assert handler.output_count == count_at_ack, "No output should occur after acknowledgment" + + handler.release_pause() + handler.stop() + t.join(timeout=1) + + +def test_animation_paused_initially_set(): + """animation_paused should be set initially (before animation starts).""" + handler = MockAnimationPauseHandshake() + assert handler.animation_paused.is_set(), "Should be paused initially" + + +def test_animation_paused_cleared_when_running(): + """animation_paused should be cleared when animation is actively running.""" + handler = MockAnimationPauseHandshake() + + t = threading.Thread(target=handler.animation_thread_loop, daemon=True) + t.start() + + # Wait for animation to start running + time.sleep(0.1) + + # Should be cleared (running) + assert not handler.animation_paused.is_set(), "Should be cleared when running" + + handler.stop() + t.join(timeout=1) diff --git a/tests/unit_tests/test_event_emitter_confirmation.py b/tests/unit_tests/test_event_emitter_confirmation.py new file mode 100644 index 000000000..45d72af6b --- /dev/null +++ b/tests/unit_tests/test_event_emitter_confirmation.py @@ -0,0 +1,189 @@ +import threading +import time + +from taskweaver.module.event_emitter import ConfirmationHandler, PostEventType, SessionEventEmitter + + +class MockConfirmationHandler(ConfirmationHandler): + def __init__(self, auto_approve: bool = True): + self.auto_approve = auto_approve + self.confirmation_requested = False + self.last_code = None + self.last_round_id = None + self.last_post_id = None + + def request_confirmation(self, code: str, round_id: str, post_id: str | None) -> bool: + self.confirmation_requested = True + self.last_code = code + self.last_round_id = round_id + self.last_post_id = post_id + return self.auto_approve + + +def test_confirmation_auto_approve_when_no_handler(): + emitter = SessionEventEmitter() + emitter.start_round("test-round-1") + + result = emitter.request_code_confirmation("print('hello')", "post-1") + + assert result is True + assert not emitter.confirmation_pending + + +def test_confirmation_pending_property(): + emitter = SessionEventEmitter() + emitter.start_round("test-round-1") + handler = MockConfirmationHandler(auto_approve=True) + emitter.confirmation_handler = handler + + def request_thread(): + emitter.request_code_confirmation("print('hello')", "post-1") + + def provide_thread(): + while not emitter.confirmation_pending: + time.sleep(0.01) + + assert emitter.confirmation_pending + assert emitter.pending_confirmation_code == "print('hello')" + + emitter.provide_confirmation(True) + + t1 = threading.Thread(target=request_thread) + t2 = threading.Thread(target=provide_thread) + + t1.start() + t2.start() + + t1.join(timeout=2) + t2.join(timeout=2) + + assert not t1.is_alive() + assert not t2.is_alive() + + +def test_confirmation_approved(): + emitter = SessionEventEmitter() + emitter.start_round("test-round-1") + handler = MockConfirmationHandler() + emitter.confirmation_handler = handler + + result = None + + def request_thread(): + nonlocal result + result = emitter.request_code_confirmation("print('test')", "post-1") + + def provide_thread(): + while not emitter.confirmation_pending: + time.sleep(0.01) + emitter.provide_confirmation(True) + + t1 = threading.Thread(target=request_thread) + t2 = threading.Thread(target=provide_thread) + + t1.start() + t2.start() + + t1.join(timeout=2) + t2.join(timeout=2) + + assert result is True + + +def test_confirmation_rejected(): + emitter = SessionEventEmitter() + emitter.start_round("test-round-1") + handler = MockConfirmationHandler() + emitter.confirmation_handler = handler + + result = None + + def request_thread(): + nonlocal result + result = emitter.request_code_confirmation("rm -rf /", "post-1") + + def provide_thread(): + while not emitter.confirmation_pending: + time.sleep(0.01) + emitter.provide_confirmation(False) + + t1 = threading.Thread(target=request_thread) + t2 = threading.Thread(target=provide_thread) + + t1.start() + t2.start() + + t1.join(timeout=2) + t2.join(timeout=2) + + assert result is False + + +def test_confirmation_events_emitted(): + emitter = SessionEventEmitter() + emitter.start_round("test-round-1") + handler = MockConfirmationHandler() + emitter.confirmation_handler = handler + + events_captured = [] + + class EventCapture: + def handle(self, event): + events_captured.append(event) + + emitter.register(EventCapture()) + + def request_thread(): + emitter.request_code_confirmation("test_code", "post-1") + + def provide_thread(): + while not emitter.confirmation_pending: + time.sleep(0.01) + emitter.provide_confirmation(True) + + t1 = threading.Thread(target=request_thread) + t2 = threading.Thread(target=provide_thread) + + t1.start() + t2.start() + + t1.join(timeout=2) + t2.join(timeout=2) + + request_events = [e for e in events_captured if e.t == PostEventType.post_confirmation_request] + response_events = [e for e in events_captured if e.t == PostEventType.post_confirmation_response] + + assert len(request_events) == 1 + assert request_events[0].msg == "test_code" + assert request_events[0].extra["code"] == "test_code" + + assert len(response_events) == 1 + assert response_events[0].msg == "approved" + assert response_events[0].extra["approved"] is True + + +def test_confirmation_state_cleared_after_response(): + emitter = SessionEventEmitter() + emitter.start_round("test-round-1") + handler = MockConfirmationHandler() + emitter.confirmation_handler = handler + + def request_thread(): + emitter.request_code_confirmation("code1", "post-1") + + def provide_thread(): + while not emitter.confirmation_pending: + time.sleep(0.01) + emitter.provide_confirmation(True) + + t1 = threading.Thread(target=request_thread) + t2 = threading.Thread(target=provide_thread) + + t1.start() + t2.start() + + t1.join(timeout=2) + t2.join(timeout=2) + + assert not emitter.confirmation_pending + assert emitter.pending_confirmation_code is None From 4a024443d006959f53e70af6404c0683142ba90b Mon Sep 17 00:00:00 2001 From: liqun Date: Mon, 26 Jan 2026 15:15:58 +0800 Subject: [PATCH 05/10] remove init plan --- docs/design/threading_model.md | 1 - .../example-planner-default-1.yaml | 10 --- .../example-planner-default-2.yaml | 5 +- .../example-planner-echo.yaml | 6 -- .../example-planner-recepta.yaml | 20 +----- taskweaver/chat/console/chat.py | 4 +- taskweaver/memory/AGENTS.md | 1 - taskweaver/memory/attachment.py | 7 +- taskweaver/memory/post.py | 10 ++- taskweaver/planner/planner.py | 2 - taskweaver/planner/planner_prompt.yaml | 64 ++++++------------- .../planner_examples/example-planner.yaml | 12 +--- .../planner_examples/sub/example-planner.yaml | 12 +--- .../data/prompts/planner_prompt.yaml | 60 +++++------------ 14 files changed, 54 insertions(+), 160 deletions(-) diff --git a/docs/design/threading_model.md b/docs/design/threading_model.md index 4048c968f..7f766f79c 100644 --- a/docs/design/threading_model.md +++ b/docs/design/threading_model.md @@ -199,7 +199,6 @@ def _animate_thread(self): ``` ╭───< Planner > - ├─► [init_plan] Analyze the user request... ├─► [plan] 1. Parse input data... ├──● The task involves processing the CSV file... ╰──● sending message to CodeInterpreter diff --git a/project/examples/planner_examples/example-planner-default-1.yaml b/project/examples/planner_examples/example-planner-default-1.yaml index 1cd2fde0d..069aae3b6 100644 --- a/project/examples/planner_examples/example-planner-default-1.yaml +++ b/project/examples/planner_examples/example-planner-default-1.yaml @@ -14,11 +14,6 @@ rounds: - type: plan_reasoning content: |- The user wants to count the rows of the data file /home/data.csv. The first step is to load the data file and count the rows of the loaded data. - - type: init_plan - content: |- - 1. Load the data file - 2. Count the rows of the loaded data - 3. Check the execution result and report the result to the user - type: plan content: |- 1. Instruct CodeInterpreter to load the data file and count the rows of the loaded data @@ -40,11 +35,6 @@ rounds: The data file /home/data.csv is loaded and there are 100 rows in the data file The execution result is correct The user query is successfully answered - - type: init_plan - content: |- - 1. Load the data file - 2. Count the rows of the loaded data - 3. Check the execution result and report the result to the user - type: plan content: |- 1. Instruct CodeInterpreter to load the data file and count the rows of the loaded data diff --git a/project/examples/planner_examples/example-planner-default-2.yaml b/project/examples/planner_examples/example-planner-default-2.yaml index ba548245d..86e61a181 100644 --- a/project/examples/planner_examples/example-planner-default-2.yaml +++ b/project/examples/planner_examples/example-planner-default-2.yaml @@ -14,13 +14,10 @@ rounds: - type: plan_reasoning content: |- The user greets the Planner - - type: init_plan - content: |- - 1. Respond to the user's greeting - type: plan content: |- 1. Respond to the user's greeting - type: current_plan_step content: 1. Respond to the user's greeting - type: stop - content: Completed \ No newline at end of file + content: Completed diff --git a/project/examples/planner_examples/example-planner-echo.yaml b/project/examples/planner_examples/example-planner-echo.yaml index f94715e74..54b02d79c 100644 --- a/project/examples/planner_examples/example-planner-echo.yaml +++ b/project/examples/planner_examples/example-planner-echo.yaml @@ -14,9 +14,6 @@ rounds: - type: plan_reasoning content: |- The user wants to echo the input 'Hello World' - - type: init_plan - content: |- - 1. Ask Echo to echo the user's input, 'Hello World' - type: plan content: |- 1. Ask Echo to echo the user's input, 'Hello World' @@ -35,9 +32,6 @@ rounds: - type: plan_reasoning content: |- The user query is successfully answered - - type: init_plan - content: |- - 1. Ask Echo to echo the user's input, 'Hello World' - type: plan content: |- 1. Ask Echo to echo the user's input, 'Hello World' diff --git a/project/examples/planner_examples/example-planner-recepta.yaml b/project/examples/planner_examples/example-planner-recepta.yaml index d32574006..075e0af71 100644 --- a/project/examples/planner_examples/example-planner-recepta.yaml +++ b/project/examples/planner_examples/example-planner-recepta.yaml @@ -48,12 +48,6 @@ rounds: - type: plan_reasoning content: |- The first step is to extract the following information from the employee summary: `Employee ID`, `Department`, `Project Code`, `Role`, `Manager`, `Location`. - - type: init_plan - content: |- - 1. extract the following information from the employee summary: `Employee ID`, `Department`, `Project Code`, `Role`, `Manager`, `Location` - 2. validate the role - 3. construct and print the SQL query using the extracted details - 4. check the execution result and report the result to the user - type: plan content: |- 1. extract information from the employee summary @@ -78,12 +72,6 @@ rounds: - type: plan_reasoning content: |- I have extracted the information from the employee summary. The next step is to verify if the role is one of the recognized roles. - - type: init_plan - content: |- - 1. extract the following information from the employee summary: `Employee ID`, `Department`, `Project Code`, `Role`, `Manager`, `Location` - 2. validate the role - 3. construct and print the SQL query using the extracted details - 4. check the execution result and report the result to the user - type: plan content: |- 1. extract information from the employee summary @@ -119,12 +107,6 @@ rounds: - type: plan_reasoning content: |- I can prepare and print the SQL query using the extracted details to the User. - - type: init_plan - content: |- - 1. extract the following information from the employee summary: `Employee ID`, `Department`, `Project Code`, `Role`, `Manager`, `Location` - 2. validate the role - 3. construct and print the SQL query using the extracted details - 4. check the execution result and report the result to the user - type: plan content: |- 1. extract information from the employee summary @@ -134,4 +116,4 @@ rounds: - type: current_plan_step content: 3. construct and print the SQL query using the extracted details and report the result to the user - type: stop - content: Completed \ No newline at end of file + content: Completed diff --git a/taskweaver/chat/console/chat.py b/taskweaver/chat/console/chat.py index 9cb6937a2..b17622d12 100644 --- a/taskweaver/chat/console/chat.py +++ b/taskweaver/chat/console/chat.py @@ -71,7 +71,7 @@ def user_input_message(prompt: str = " Human ") -> str: continue -def user_confirmation_input(prompt: str = "Execute code? [y/N]: ") -> str: +def user_confirmation_input(prompt: str = "Execute code? [Y/n]: ") -> str: import prompt_toolkit session = prompt_toolkit.PromptSession[str]( @@ -272,7 +272,7 @@ def _handle_confirmation(self, session: Session) -> None: click.secho(click.style(" │ ", fg="blue") + click.style(line, fg="bright_black")) response = user_confirmation_input() - approved = response in ("y", "yes") + approved = response not in ("n", "no") if approved: click.secho(click.style(" │ ", fg="blue") + click.style("✓ approved", fg="green")) diff --git a/taskweaver/memory/AGENTS.md b/taskweaver/memory/AGENTS.md index 355379f46..9cc5dc752 100644 --- a/taskweaver/memory/AGENTS.md +++ b/taskweaver/memory/AGENTS.md @@ -57,7 +57,6 @@ class Post: ```python class AttachmentType(str, Enum): # Planning - init_plan = "init_plan" plan = "plan" current_plan_step = "current_plan_step" diff --git a/taskweaver/memory/attachment.py b/taskweaver/memory/attachment.py index 609f9524c..dad72516e 100644 --- a/taskweaver/memory/attachment.py +++ b/taskweaver/memory/attachment.py @@ -9,7 +9,6 @@ class AttachmentType(Enum): # Planner Type - init_plan = "init_plan" plan = "plan" current_plan_step = "current_plan_step" plan_reasoning = "plan_reasoning" @@ -114,7 +113,7 @@ def to_dict(self) -> AttachmentDict: } @staticmethod - def from_dict(content: AttachmentDict) -> Attachment: + def from_dict(content: AttachmentDict) -> Optional[Attachment]: # deprecated types if content["type"] in ["python", "sample", "text"]: raise ValueError( @@ -123,6 +122,10 @@ def from_dict(content: AttachmentDict) -> Attachment: f"on how to fix it.", ) + removed_types = ["init_plan"] + if content["type"] in removed_types: + return None + type = AttachmentType(content["type"]) return Attachment.create( type=type, diff --git a/taskweaver/memory/post.py b/taskweaver/memory/post.py index 4b053691c..eafb06790 100644 --- a/taskweaver/memory/post.py +++ b/taskweaver/memory/post.py @@ -73,14 +73,18 @@ def to_dict(self) -> Dict[str, Any]: @staticmethod def from_dict(content: Dict[str, Any]) -> Post: """Convert the dict to a post. Will assign a new id to the post.""" + attachments = [] + if content["attachment_list"] is not None: + for attachment in content["attachment_list"]: + parsed = Attachment.from_dict(attachment) + if parsed is not None: + attachments.append(parsed) return Post( id="post-" + secrets.token_hex(6), message=content["message"], send_from=content["send_from"], send_to=content["send_to"], - attachment_list=[Attachment.from_dict(attachment) for attachment in content["attachment_list"]] - if content["attachment_list"] is not None - else [], + attachment_list=attachments, ) def add_attachment(self, attachment: Attachment) -> None: diff --git a/taskweaver/planner/planner.py b/taskweaver/planner/planner.py index 31de46201..c5329222b 100644 --- a/taskweaver/planner/planner.py +++ b/taskweaver/planner/planner.py @@ -268,8 +268,6 @@ def check_post_validity(post: Post): missing_elements.append("message") attachment_types = [attachment.type for attachment in post.attachment_list] - if AttachmentType.init_plan not in attachment_types: - missing_elements.append("init_plan") if AttachmentType.plan not in attachment_types: missing_elements.append("plan") if AttachmentType.current_plan_step not in attachment_types: diff --git a/taskweaver/planner/planner_prompt.yaml b/taskweaver/planner/planner_prompt.yaml index b3651dd10..32ab3a954 100644 --- a/taskweaver/planner/planner_prompt.yaml +++ b/taskweaver/planner/planner_prompt.yaml @@ -44,24 +44,16 @@ instruction_template: |- 2. Planner use its own skills to complete the task step, which is recommended when the task step is simple. ## Planner's planning process - You need to make a step-by-step plan to complete the User's task. The planning process includes 2 phases: `init_plan` and `plan`. - In the `init_plan` phase, you need to decompose the User's task into subtasks and list them as the detailed plan steps. - In the `plan` phase, you need to refine the initial plan by merging adjacent steps that have sequential dependency or no dependency, unless the merged step becomes too complicated. - - ### init_plan - - Decompose User's task into subtasks and list them as the detailed subtask steps. - - Annotate the dependencies between these steps. There are 2 dependency types: - 1. Sequential Dependency: the current subtask depends on the previous subtask, but they can be executed in one step by a Worker, - and no additional information is required. - 2. Interactive Dependency: the current subtask depends on the previous subtask but they cannot be executed in one step by a Worker, - typically without necessary information (e.g., hyperparameters, data path, model name, file content, data schema, etc.). - 3. No Dependency: the current subtask can be executed independently without any dependency. - - The initial plan must contain dependency annotations for sequential and interactive dependencies. - - ### plan - - Planner should try to merge adjacent steps that have sequential dependency or no dependency. - - Planner should not merge steps with interactive dependency. - - The final plan must not contain dependency annotations. + You need to make a step-by-step plan to complete the User's task. + When creating the plan, you should mentally decompose the task into subtasks and consider their dependencies: + - Sequential Dependency: the current subtask depends on the previous subtask, but they can be executed in one step by a Worker, and no additional information is required. + - Interactive Dependency: the current subtask depends on the previous subtask but they cannot be executed in one step by a Worker, typically without necessary information (e.g., hyperparameters, data path, model name, file content, data schema, etc.). + - No Dependency: the current subtask can be executed independently without any dependency. + + Based on this analysis, create a compact plan by: + - Merging adjacent steps that have sequential dependency or no dependency into single steps + - Keeping steps with interactive dependency separate (they require intermediate results before proceeding) + - The final plan should be concise and actionable, without dependency annotations ## Planner's communication process - Planner should communicate with the User and Workers by specifying the `send_to` field in the response. @@ -76,35 +68,27 @@ instruction_template: |- + UserCancelled: The User has explicitly cancelled the operation (e.g., declined code execution confirmation). Do NOT retry or continue the task - stop immediately and acknowledge the cancellation. - ### Examples of planning process + ### Examples of planning + The examples below show how to think about task decomposition and create compact plans: + [Example 1] User: count rows for ./data.csv - init_plan: - 1. Read ./data.csv file - 2. Count the rows of the loaded data - 3. Check the execution result and report the result to the user + Reasoning: Reading and counting can be done in one step (sequential dependency), but we need execution results before reporting (interactive dependency). plan: 1. Read ./data.csv file and count the rows of the loaded data 2. Check the execution result and report the result to the user [Example 2] User: Read a manual file and follow the instructions in it. - init_plan: - 1. Read the file content and show its content to the user - 2. Follow the instructions based on the file content. - 3. Confirm the completion of the instructions and report the result to the user + Reasoning: We must read the file first to know what instructions to follow (interactive dependency), then execute them (interactive dependency). plan: 1. Read the file content and show its content to the user - 2. follow the instructions based on the file content. + 2. Follow the instructions based on the file content 3. Confirm the completion of the instructions and report the result to the user [Example 3] User: detect anomaly on ./data.csv - init_plan: - 1. Read the ./data.csv and show me the top 5 rows to understand the data schema - 2. Confirm the columns to be detected anomalies - 3. Detect anomalies on the loaded data - 4. Check the execution result and report the detected anomalies to the user + Reasoning: Reading data and confirming columns can be merged (sequential), but anomaly detection needs the confirmed columns (interactive). plan: 1. Read the ./data.csv and show me the top 5 rows to understand the data schema and confirm the columns to be detected anomalies 2. Detect anomalies on the loaded data @@ -112,12 +96,7 @@ instruction_template: |- [Example 4] User: read a.csv and b.csv and join them together - init_plan: - 1. Load a.csv as dataframe and show me the top 5 rows to understand the data schema - 2. Load b.csv as dataframe and show me the top 5 rows to understand the data schema - 3. Ask which column to join - 4. Join the two dataframes - 5. Check the execution result and report the joined data to the user + Reasoning: Loading both files and asking about join column can be merged (sequential/no dependency), but joining needs the column choice (interactive). plan: 1. Load a.csv and b.csv as dataframes, show me the top 5 rows to understand the data schema, and ask which column to join 2. Join the two dataframes @@ -150,13 +129,9 @@ response_json_schema: |- "type": "string", "description": "The reasoning of the Planner's decision. It should include the analysis of the User's request, the Workers' responses, and the current environment context." }, - "init_plan": { - "type": "string", - "description": "The initial plan to decompose the User's task into subtasks and list them as the detailed subtask steps. The initial plan must contain dependency annotations for sequential and interactive dependencies." - }, "plan": { "type": "string", - "description": "The refined plan by merging adjacent steps that have sequential dependency or no dependency. The final plan must not contain dependency annotations." + "description": "The step-by-step plan to complete the User's task. Steps with sequential or no dependency should be merged. Steps with interactive dependency should be kept separate." }, "current_plan_step": { "type": "string", @@ -178,7 +153,6 @@ response_json_schema: |- }, "required": [ "plan_reasoning", - "init_plan", "plan", "current_plan_step", "stop", diff --git a/tests/unit_tests/data/examples/planner_examples/example-planner.yaml b/tests/unit_tests/data/examples/planner_examples/example-planner.yaml index 525463ab3..066396022 100644 --- a/tests/unit_tests/data/examples/planner_examples/example-planner.yaml +++ b/tests/unit_tests/data/examples/planner_examples/example-planner.yaml @@ -11,11 +11,6 @@ rounds: send_from: Planner send_to: CodeInterpreter attachment_list: - - type: init_plan - content: |- - 1. load the data file - 2. count the rows of the loaded data - 3. report the result to the user - type: plan content: |- 1. instruct CodeInterpreter to load the data file and count the rows of the loaded data @@ -30,14 +25,9 @@ rounds: send_from: Planner send_to: User attachment_list: - - type: init_plan - content: |- - 1. load the data file - 2. count the rows of the loaded data - 3. report the result to the user - type: plan content: |- 1. instruct CodeInterpreter to load the data file and count the rows of the loaded data 2. report the result to the user - type: current_plan_step - content: 2. report the result to the user \ No newline at end of file + content: 2. report the result to the user diff --git a/tests/unit_tests/data/examples/planner_examples/sub/example-planner.yaml b/tests/unit_tests/data/examples/planner_examples/sub/example-planner.yaml index 525463ab3..066396022 100644 --- a/tests/unit_tests/data/examples/planner_examples/sub/example-planner.yaml +++ b/tests/unit_tests/data/examples/planner_examples/sub/example-planner.yaml @@ -11,11 +11,6 @@ rounds: send_from: Planner send_to: CodeInterpreter attachment_list: - - type: init_plan - content: |- - 1. load the data file - 2. count the rows of the loaded data - 3. report the result to the user - type: plan content: |- 1. instruct CodeInterpreter to load the data file and count the rows of the loaded data @@ -30,14 +25,9 @@ rounds: send_from: Planner send_to: User attachment_list: - - type: init_plan - content: |- - 1. load the data file - 2. count the rows of the loaded data - 3. report the result to the user - type: plan content: |- 1. instruct CodeInterpreter to load the data file and count the rows of the loaded data 2. report the result to the user - type: current_plan_step - content: 2. report the result to the user \ No newline at end of file + content: 2. report the result to the user diff --git a/tests/unit_tests/data/prompts/planner_prompt.yaml b/tests/unit_tests/data/prompts/planner_prompt.yaml index 851daf707..84133a835 100644 --- a/tests/unit_tests/data/prompts/planner_prompt.yaml +++ b/tests/unit_tests/data/prompts/planner_prompt.yaml @@ -33,54 +33,38 @@ instruction_template: |- - Planner can ignore the permission or file access issues since Workers are powerful and can handle them. ## Planner's planning process - You need to make a step-by-step plan to complete the User's task. The planning process includes 2 phases: `init_plan` and `plan`. - In the `init_plan` phase, you need to decompose the User's task into subtasks and list them as the detailed plan steps. - In the `plan` phase, you need to refine the initial plan by merging adjacent steps that have sequential dependency or no dependency, unless the merged step becomes too complicated. + You need to make a step-by-step plan to complete the User's task. + When creating the plan, you should mentally decompose the task into subtasks and consider their dependencies: + - Sequential Dependency: the current subtask depends on the previous subtask, but they can be executed in one step by a Worker, and no additional information is required. + - Interactive Dependency: the current subtask depends on the previous subtask but they cannot be executed in one step by a Worker, typically without necessary information (e.g., hyperparameters, data path, model name, file content, data schema, etc.). + - No Dependency: the current subtask can be executed independently without any dependency. - ### init_plan - - Decompose User's task into subtasks and list them as the detailed subtask steps. - - Annotate the dependencies between these steps. There are 2 dependency types: - 1. Sequential Dependency: the current subtask depends on the previous subtask, but they can be executed in one step by a Worker, - and no additional information is required. - 2. Interactive Dependency: the current subtask depends on the previous subtask but they cannot be executed in one step by a Worker, - typically without necessary information (e.g., hyperparameters, data path, model name, file content, data schema, etc.). - 3. No Dependency: the current subtask can be executed independently without any dependency. - - The initial plan must contain dependency annotations for sequential and interactive dependencies. + Based on this analysis, create a compact plan by: + - Merging adjacent steps that have sequential dependency or no dependency into single steps + - Keeping steps with interactive dependency separate (they require intermediate results before proceeding) + - The final plan should be concise and actionable, without dependency annotations - ### plan - - Planner should try to merge adjacent steps that have sequential dependency or no dependency. - - Planner should not merge steps with interactive dependency. - - The final plan must not contain dependency annotations. + ### Examples of planning + The examples below show how to think about task decomposition and create compact plans: - ### Examples of planning process [Example 1] User: count rows for ./data.csv - init_plan: - 1. Read ./data.csv file - 2. Count the rows of the loaded data - 3. Check the execution result and report the result to the user + Reasoning: Reading and counting can be done in one step (sequential dependency), but we need execution results before reporting (interactive dependency). plan: 1. Read ./data.csv file and count the rows of the loaded data 2. Check the execution result and report the result to the user [Example 2] User: Read a manual file and follow the instructions in it. - init_plan: - 1. Read the file content and show its content to the user - 2. Follow the instructions based on the file content. - 3. Confirm the completion of the instructions and report the result to the user + Reasoning: We must read the file first to know what instructions to follow (interactive dependency), then execute them (interactive dependency). plan: 1. Read the file content and show its content to the user - 2. follow the instructions based on the file content. + 2. Follow the instructions based on the file content 3. Confirm the completion of the instructions and report the result to the user [Example 3] User: detect anomaly on ./data.csv - init_plan: - 1. Read the ./data.csv and show me the top 5 rows to understand the data schema - 2. Confirm the columns to be detected anomalies - 3. Detect anomalies on the loaded data - 4. Check the execution result and report the detected anomalies to the user + Reasoning: Reading data and confirming columns can be merged (sequential), but anomaly detection needs the confirmed columns (interactive). plan: 1. Read the ./data.csv and show me the top 5 rows to understand the data schema and confirm the columns to be detected anomalies 2. Detect anomalies on the loaded data @@ -88,12 +72,7 @@ instruction_template: |- [Example 4] User: read a.csv and b.csv and join them together - init_plan: - 1. Load a.csv as dataframe and show me the top 5 rows to understand the data schema - 2. Load b.csv as dataframe and show me the top 5 rows to understand the data schema - 3. Ask which column to join - 4. Join the two dataframes - 5. Check the execution result and report the joined data to the user + Reasoning: Loading both files and asking about join column can be merged (sequential/no dependency), but joining needs the column choice (interactive). plan: 1. Load a.csv and b.csv as dataframes, show me the top 5 rows to understand the data schema, and ask which column to join 2. Join the two dataframes @@ -120,13 +99,9 @@ response_json_schema: |- "response": { "type": "object", "properties": { - "init_plan": { - "type": "string", - "description": "The initial plan to decompose the User's task into subtasks and list them as the detailed subtask steps. The initial plan must contain dependency annotations for sequential and interactive dependencies." - }, "plan": { "type": "string", - "description": "The refined plan by merging adjacent steps that have sequential dependency or no dependency. The final plan must not contain dependency annotations." + "description": "The step-by-step plan to complete the User's task. Steps with sequential or no dependency should be merged. Steps with interactive dependency should be kept separate." }, "current_plan_step": { "type": "string", @@ -146,7 +121,6 @@ response_json_schema: |- } }, "required": [ - "init_plan", "plan", "current_plan_step", "send_to", From 91570b8d052e1eb6db4b7624dde9b0a2d59d519e Mon Sep 17 00:00:00 2001 From: liqun Date: Tue, 27 Jan 2026 11:27:50 +0800 Subject: [PATCH 06/10] stream back plugin prints --- AGENTS.md | 2 +- docs/design/plugin-execution-streaming.md | 538 ++++++++++++++++++ project/plugins/long_running_demo.py | 26 + project/plugins/long_running_demo.yaml | 24 + taskweaver/ces/common.py | 9 +- taskweaver/ces/environment.py | 37 +- taskweaver/ces/manager/sub_proc.py | 16 +- taskweaver/chat/console/chat.py | 19 +- taskweaver/code_interpreter/code_executor.py | 11 +- .../code_interpreter/code_interpreter.py | 4 + taskweaver/memory/AGENTS.md | 38 +- taskweaver/module/event_emitter.py | 9 + 12 files changed, 714 insertions(+), 19 deletions(-) create mode 100644 docs/design/plugin-execution-streaming.md create mode 100644 project/plugins/long_running_demo.py create mode 100644 project/plugins/long_running_demo.yaml diff --git a/AGENTS.md b/AGENTS.md index 227e2edbe..3da2033ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md - TaskWeaver Development Guide -**Generated:** 2026-01-26 | **Commit:** 7c2888e | **Branch:** liqun/add_variables_to_code_generator +**Generated:** 2026-01-26 | **Commit:** 4a02444 | **Branch:** liqun/add_variables_to_code_generator This document provides guidance for AI coding agents working on the TaskWeaver codebase. diff --git a/docs/design/plugin-execution-streaming.md b/docs/design/plugin-execution-streaming.md new file mode 100644 index 000000000..98ade80b8 --- /dev/null +++ b/docs/design/plugin-execution-streaming.md @@ -0,0 +1,538 @@ +# Plugin Execution Output Streaming - Design Document + +**Generated:** 2026-01-26 | **Author:** AI Agent | **Status:** Analysis Complete + +## Problem + +When a plugin (Python function) executes long-running operations with `print()` statements or `ctx.log()` calls, the output only appears **after** execution completes, not during. This creates a poor user experience for operations that take seconds or minutes. + +**Example scenario:** +```python +# Plugin function +def long_running_analysis(ctx: PluginContext, data: pd.DataFrame): + print("Starting analysis...") # User doesn't see this until end + for i in range(100): + # ... heavy computation ... + print(f"Progress: {i}%") # User doesn't see this until end + print("Done!") + return result +``` + +The user sees nothing for the entire execution duration, then all output appears at once. + +## Root Cause Analysis + +### Execution Flow + +``` +CodeInterpreter.reply() + └── CodeExecutor.execute_code() + └── Environment._execute_code_on_kernel() + └── BlockingKernelClient.execute() + └── while loop: kc.get_iopub_msg() + ├── msg_type == "stream" → stdout/stderr COLLECTED + └── msg_type == "status" idle → break (done) +``` + +### The Bottleneck: `_execute_code_on_kernel()` + +In `taskweaver/ces/environment.py` (lines 518-595), the kernel message loop: + +```python +def _execute_code_on_kernel(self, code: str, ...) -> ExecutionResult: + exec_result = ExecutionResult() + + # ... setup ... + + while True: + msg = kc.get_iopub_msg() # Jupyter sends messages AS they happen + msg_type = msg["msg_type"] + + if msg_type == "stream": + stream_name = msg["content"]["name"] + stream_text = msg["content"]["text"] + if stream_name == "stdout": + exec_result.stdout.append(stream_text) # <-- BATCHED, not streamed + elif stream_name == "stderr": + exec_result.stderr.append(stream_text) # <-- BATCHED, not streamed + + elif msg_type == "status": + if msg["content"]["execution_state"] == "idle": + break # Only returns AFTER execution completes + + return exec_result # All output returned at once +``` + +**Key insight**: The Jupyter kernel **does** send `stream` messages in real-time as `print()` is called. TaskWeaver receives them but **collects** them into lists instead of forwarding them immediately. + +### Why It's Batched + +The current architecture has no mechanism to push intermediate output to the UI during execution: + +1. **No callback/event mechanism** in `_execute_code_on_kernel()` +2. **Synchronous return** - function returns only when execution completes +3. **No event emission** for partial stdout/stderr +4. **UI thread isolation** - execution runs in separate thread from animation + +## Current Architecture + +### Relevant Components + +| Component | File | Role | +|-----------|------|------| +| `Environment._execute_code_on_kernel()` | `ces/environment.py` | Receives kernel messages, batches stdout | +| `ExecutionResult` | `ces/common.py` | Dataclass holding stdout/stderr lists | +| `CodeExecutor.execute_code()` | `code_interpreter/code_executor.py` | Wraps Environment, formats output | +| `CodeInterpreter.reply()` | `code_interpreter/code_interpreter/code_interpreter.py` | Orchestrates execution, emits events | +| `SessionEventEmitter` | `module/event_emitter.py` | Event dispatch to handlers | +| `PostEventProxy` | `module/event_emitter.py` | Per-post event wrapper | +| `TaskWeaverRoundUpdater` | `chat/console/chat.py` | Console UI event handler | +| `ExecutorPluginContext` | `ces/runtime/context.py` | Plugin's `ctx.log()` implementation | + +### Event System + +The event system already supports real-time updates: + +```python +class PostEventType(Enum): + post_status_update = "post_status_update" # Status text changes + post_message_update = "post_message_update" # Message content streaming + post_attachment_update = "post_attachment_update" # Attachment updates + # ... others +``` + +LLM response streaming uses `post_message_update` to show tokens as they arrive. The same pattern could work for execution output. + +### Threading Model + +``` +┌─────────────────────────────┐ ┌─────────────────────────────┐ +│ Execution Thread (t_ex) │ │ Animation Thread (t_ui) │ +│ │ │ │ +│ session.send_message() │ │ _animate_thread() │ +│ ├── Planner.reply() │ │ ├── Process updates │ +│ ├── CodeInterpreter │ │ ├── Render status bar │ +│ .reply() │ │ └── Display messages │ +│ └── execute_code() │ │ │ +│ └── BLOCKED │ │ │ +│ waiting │ │ │ +│ for kernel │ │ │ +│ │ │ │ +│ Event emission ──────────┼───┼──► pending_updates queue │ +└─────────────────────────────┘ └─────────────────────────────┘ +``` + +The execution thread blocks in `_execute_code_on_kernel()` while the animation thread is ready to display updates - but no updates are sent during execution. + +## Proposed Solution + +### Design Approach + +Add an **optional callback** to `_execute_code_on_kernel()` that fires for each `stream` message. This callback can emit events to the UI in real-time. + +### Changes Overview + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ Proposed Changes │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Environment._execute_code_on_kernel() │ +│ └── Add: on_output callback parameter │ +│ └── Call: on_output(stream_name, text) for each stream message │ +│ │ +│ 2. CodeExecutor.execute_code() │ +│ └── Add: on_output parameter, pass to Environment │ +│ │ +│ 3. CodeInterpreter.reply() │ +│ └── Create callback that emits PostEventType.post_execution_output │ +│ └── Pass callback to execute_code() │ +│ │ +│ 4. PostEventType (event_emitter.py) │ +│ └── Add: post_execution_output event type │ +│ │ +│ 5. TaskWeaverRoundUpdater (chat.py) │ +│ └── Handle: post_execution_output events │ +│ └── Display: incremental stdout/stderr in console │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +### Detailed Design + +#### 1. Environment Layer + +```python +# ces/environment.py + +def _execute_code_on_kernel( + self, + code: str, + session_id: str, + ..., + on_output: Optional[Callable[[str, str], None]] = None, # NEW +) -> ExecutionResult: + """ + Args: + on_output: Optional callback(stream_name, text) called for each stdout/stderr chunk + """ + exec_result = ExecutionResult() + + while True: + msg = kc.get_iopub_msg() + msg_type = msg["msg_type"] + + if msg_type == "stream": + stream_name = msg["content"]["name"] + stream_text = msg["content"]["text"] + + # NEW: Fire callback for real-time streaming + if on_output is not None: + on_output(stream_name, stream_text) + + # Still collect for final result (backward compatibility) + if stream_name == "stdout": + exec_result.stdout.append(stream_text) + elif stream_name == "stderr": + exec_result.stderr.append(stream_text) + + # ... rest unchanged +``` + +#### 2. CodeExecutor Layer + +```python +# code_interpreter/code_executor.py + +def execute_code( + self, + exec_id: str, + code: str, + on_output: Optional[Callable[[str, str], None]] = None, # NEW +) -> Tuple[ExecutionResult, str]: + """ + Args: + on_output: Optional callback for streaming stdout/stderr during execution + """ + result = self.env.execute_code( + code, + session_id=self.session_id, + on_output=on_output, # Pass through + ) + # ... format output ... +``` + +#### 3. Event Type + +```python +# module/event_emitter.py + +class PostEventType(Enum): + post_status_update = "post_status_update" + post_message_update = "post_message_update" + post_attachment_update = "post_attachment_update" + post_execution_output = "post_execution_output" # NEW + # ... +``` + +#### 4. CodeInterpreter Layer + +```python +# code_interpreter/code_interpreter/code_interpreter.py + +def reply(self, ...): + # ... code generation, verification ... + + # Create streaming callback + def on_execution_output(stream_name: str, text: str): + self.post_proxy.emit_execution_output(stream_name, text) + + # Execute with streaming + exec_result, output = self.executor.execute_code( + exec_id=exec_id, + code=code, + on_output=on_execution_output, # NEW + ) +``` + +#### 5. PostEventProxy Extension + +```python +# module/event_emitter.py + +class PostEventProxy: + def emit_execution_output(self, stream_name: str, text: str): + """Emit real-time execution output (stdout/stderr).""" + self.event_emitter.emit( + TaskWeaverEvent( + scope=EventScope.post, + event_type=PostEventType.post_execution_output.value, + post_id=self.post_id, + extra={"stream": stream_name, "text": text}, + ), + ) +``` + +#### 6. Console UI Handler + +```python +# chat/console/chat.py + +class TaskWeaverRoundUpdater(SessionEventHandlerBase): + def handle(self, event: TaskWeaverEvent): + # ... existing handlers ... + + elif event.event_type == PostEventType.post_execution_output.value: + stream_name = event.extra["stream"] + text = event.extra["text"] + with self.lock: + # Display immediately without clearing status line + self.pending_updates.append(("execution_output", (stream_name, text))) + with self.update_cond: + self.update_cond.notify_all() + + def _animate_thread(self): + # ... in update processing loop ... + + for action, opt in self.pending_updates: + if action == "execution_output": + stream_name, text = opt + # Print execution output inline + prefix = "" if stream_name == "stdout" else "[stderr] " + sys.stdout.write(f" {prefix}{text}") + sys.stdout.flush() +``` + +### Console Output Format + +``` + ╭───< CodeInterpreter > + ├─► [code] import time; print("Starting..."); time.sleep(5); print("Done") + ├─► [verification] CORRECT + │ Starting... ← NEW: Appears immediately when print() executes + │ Done ← NEW: Appears when print() executes + ├─► [execution_status] SUCCESS + ╰──● sending message to Planner +``` + +## Alternative Approaches Considered + +### 1. Async/Generator Pattern + +Instead of callbacks, make `_execute_code_on_kernel()` a generator: + +```python +def _execute_code_on_kernel(self, code: str, ...) -> Generator[Tuple[str, str], None, ExecutionResult]: + while True: + msg = kc.get_iopub_msg() + if msg_type == "stream": + yield ("stream", stream_name, text) # Yield intermediate output + elif msg_type == "status" and idle: + return exec_result # Return final result +``` + +**Rejected because**: Requires significant refactoring of callers; callback is simpler and backward-compatible. + +### 2. Separate Thread for Streaming + +Spawn a dedicated thread to poll kernel messages and emit events: + +```python +def _execute_code_on_kernel(self, ...): + def stream_poller(): + while not done: + msg = kc.get_iopub_msg(timeout=0.1) + if msg_type == "stream": + emit_event(...) + + thread = Thread(target=stream_poller) + thread.start() + # ... wait for execution ... +``` + +**Rejected because**: Adds threading complexity; callback achieves same result more simply. + +### 3. Plugin Context Enhancement (`ctx.log()`) + +Enhance `ctx.log()` to stream immediately instead of batching: + +```python +# In ExecutorPluginContext +def log(self, message: str): + # Instead of appending to list, emit event immediately + self._emit_log_event(message) +``` + +**Partial solution**: Only helps plugins using `ctx.log()`, not raw `print()`. Should be done as follow-up. + +## Execution Mode Support + +### Both Local and Container Modes Supported + +The proposed design works identically for both execution modes because **both use the same `_execute_code_on_kernel()` method**. + +#### Architecture Analysis + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ _execute_code_on_kernel() [lines 518-595] │ +│ │ +│ while True: │ +│ msg = kc.get_iopub_msg() ◄─────┬─────────────────────────────────┐ │ +│ if msg_type == "stream": │ │ │ +│ on_output(stream_name, text)│ │ │ +│ │ │ │ +└────────────────────────────────────────┼─────────────────────────────────┼──┘ + │ │ + ┌────────────────────┴────────────────┐ ┌────────────┴───────────┐ + │ Local Mode │ │ Container Mode │ + ├─────────────────────────────────────┤ ├────────────────────────┤ + │ BlockingKernelClient │ │ BlockingKernelClient │ + │ └── Direct ZMQ connection │ │ └── TCP to localhost │ + │ to local kernel │ │ port (mapped) │ + │ │ │ │ + │ MultiKernelManager │ │ Docker Container │ + │ └── Spawns kernel process │ │ └── iopub port 12346 │ + │ locally │ │ mapped to host │ + └─────────────────────────────────────┘ └────────────────────────┘ +``` + +#### Why Both Modes Work + +1. **Same message loop**: Both modes call `kc.get_iopub_msg()` in the same `while` loop +2. **Same message format**: Jupyter's ZMQ protocol is identical regardless of transport +3. **Transparent routing**: Container mode uses Docker port mapping - the `BlockingKernelClient` doesn't know it's talking to a container + +From `_get_client()` (environment.py lines 489-511): +```python +if self.mode == EnvMode.Container: + client.ip = "127.0.0.1" + client.iopub_port = ports["iopub_port"] # Mapped host port +``` + +The client connects to `127.0.0.1:{mapped_port}` which Docker routes to the container's iopub socket. **Stream messages arrive identically.** + +#### No Mode-Specific Code Needed + +The callback addition is purely in `_execute_code_on_kernel()`, which is mode-agnostic: + +```python +def _execute_code_on_kernel(self, ..., on_output: Optional[Callable] = None): + # No mode checks needed - kc.get_iopub_msg() works the same way + while True: + msg = kc.get_iopub_msg() + if msg_type == "stream": + if on_output: + on_output(stream_name, stream_text) # Works for both modes +``` + +## Risks and Mitigations + +| Risk | Mitigation | +|------|------------| +| **High-frequency output** (tight loops with print) | Rate-limit events; batch rapid messages | +| **Console flicker** | Buffer updates; use ANSI escape for smooth rendering | +| **Thread safety** | Callback executes in execution thread; use lock for UI state | +| **Backward compatibility** | Callback is optional; existing code unchanged | +| **Network latency (container)** | Docker localhost routing adds negligible latency (<1ms) | + +### Rate Limiting Strategy + +For rapid output (e.g., progress bars), batch messages: + +```python +class OutputBatcher: + def __init__(self, emit_fn, interval=0.1): + self.buffer = [] + self.last_emit = time.time() + self.emit_fn = emit_fn + + def add(self, stream_name, text): + self.buffer.append((stream_name, text)) + if time.time() - self.last_emit > self.interval: + self.flush() + + def flush(self): + if self.buffer: + # Combine buffered text + combined = "".join(text for _, text in self.buffer) + self.emit_fn("stdout", combined) + self.buffer.clear() + self.last_emit = time.time() +``` + +## Testing Strategy + +### Unit Tests + +1. **Callback invocation**: Verify `on_output` called for each stream message +2. **Event emission**: Verify `post_execution_output` events emitted +3. **Backward compatibility**: Verify existing code works without callback + +### Integration Tests + +1. **Console display**: Verify streaming output appears during execution +2. **Multi-line output**: Verify proper formatting of multi-line prints +3. **Mixed stdout/stderr**: Verify both streams handled correctly +4. **Long-running execution**: Verify output appears incrementally over time +5. **Local mode**: Test with `EnvMode.Local` configuration +6. **Container mode**: Test with `EnvMode.Container` configuration (requires Docker) + +### Manual Testing + +```python +# Test plugin +def test_streaming(ctx): + import time + for i in range(5): + print(f"Progress: {i+1}/5") + time.sleep(1) + return "Done" +``` + +Expected: "Progress: 1/5" appears after 1s, "Progress: 2/5" after 2s, etc. + +## Implementation Plan + +### Phase 1: Core Streaming (MVP) +1. Add `on_output` callback to `Environment._execute_code_on_kernel()` +2. Add `post_execution_output` event type +3. Wire callback through `CodeExecutor` → `CodeInterpreter` +4. Handle event in `TaskWeaverRoundUpdater` + +### Phase 2: Polish +1. Add rate limiting for high-frequency output +2. Improve console formatting (indentation, colors) +3. Handle stderr distinctly (red text?) + +### Phase 3: Extended Support +1. Enhance `ctx.log()` to use same streaming mechanism +2. Add web UI support for streaming execution output +3. Consider progress bar support (detect and render specially) + +## Files to Modify + +| File | Changes | +|------|---------| +| `taskweaver/ces/environment.py` | Add `on_output` callback to `_execute_code_on_kernel()` | +| `taskweaver/ces/common.py` | No changes needed (ExecutionResult unchanged) | +| `taskweaver/code_interpreter/code_executor.py` | Pass `on_output` through to Environment | +| `taskweaver/code_interpreter/code_interpreter/code_interpreter.py` | Create and pass callback | +| `taskweaver/module/event_emitter.py` | Add `post_execution_output` event type, `emit_execution_output()` method | +| `taskweaver/chat/console/chat.py` | Handle `post_execution_output` events in UI | + +## Open Questions + +1. ~~**Container mode**: Does output streaming work through Docker container boundary?~~ **RESOLVED**: Yes, both modes use the same `_execute_code_on_kernel()` with identical message handling. See "Execution Mode Support" section. +2. **Web UI**: Should this be extended to web interface? (SSE/WebSocket) +3. **Truncation**: Should very long output be truncated during streaming? +4. **Interactivity**: Could this enable input prompts during execution? (Future) + +## References + +- [Threading Model Design Doc](./threading_model.md) +- [Code Interpreter Variables Design Doc](./code-interpreter-vars.md) +- [Jupyter Messaging Protocol](https://jupyter-client.readthedocs.io/en/stable/messaging.html) +- `taskweaver/ces/environment.py` - Core execution logic +- `taskweaver/module/event_emitter.py` - Event system +- `taskweaver/chat/console/chat.py` - Console UI diff --git a/project/plugins/long_running_demo.py b/project/plugins/long_running_demo.py new file mode 100644 index 000000000..d915bc9f7 --- /dev/null +++ b/project/plugins/long_running_demo.py @@ -0,0 +1,26 @@ +import sys +import time + +from taskweaver.plugin import Plugin, register_plugin + + +@register_plugin +class LongRunningDemo(Plugin): + """ + A demo plugin that simulates a long-running task with progress updates. + This demonstrates real-time streaming of print() output during execution. + """ + + def __call__(self, steps: int = 5, delay: float = 1.0) -> str: + print(f"Starting long-running task with {steps} steps...") + sys.stdout.flush() + + for i in range(1, steps + 1): + time.sleep(delay) + print(f"Progress: {i}/{steps} - Processing step {i}") + sys.stdout.flush() + + print("Task completed successfully!") + sys.stdout.flush() + + return f"Completed {steps} steps in {steps * delay:.1f} seconds" diff --git a/project/plugins/long_running_demo.yaml b/project/plugins/long_running_demo.yaml new file mode 100644 index 000000000..c74b6b4ab --- /dev/null +++ b/project/plugins/long_running_demo.yaml @@ -0,0 +1,24 @@ +name: long_running_demo +enabled: true +required: false +plugin_only: true +description: >- + A demo plugin that simulates a long-running task with progress updates. + This plugin demonstrates real-time streaming of print() output during execution. +examples: |- + result = long_running_demo(steps=5, delay=1.0) + +parameters: + - name: steps + type: int + required: false + description: Number of steps to simulate (default 5). + - name: delay + type: float + required: false + description: Delay between steps in seconds (default 1.0). + +returns: + - name: result + type: str + description: A summary of the completed task. diff --git a/taskweaver/ces/common.py b/taskweaver/ces/common.py index 5fd5f1f7e..40517bd1c 100644 --- a/taskweaver/ces/common.py +++ b/taskweaver/ces/common.py @@ -4,7 +4,7 @@ import secrets from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Tuple, Union if TYPE_CHECKING: from taskweaver.plugin.context import ArtifactType @@ -102,7 +102,12 @@ def update_session_var(self, session_var_dict: Dict[str, str]) -> None: ... @abstractmethod - def execute_code(self, exec_id: str, code: str) -> ExecutionResult: + def execute_code( + self, + exec_id: str, + code: str, + on_output: Optional[Callable[[str, str], None]] = None, + ) -> ExecutionResult: ... diff --git a/taskweaver/ces/environment.py b/taskweaver/ces/environment.py index 5b2cfe868..b7f666699 100644 --- a/taskweaver/ces/environment.py +++ b/taskweaver/ces/environment.py @@ -7,7 +7,7 @@ import time from ast import literal_eval from dataclasses import dataclass, field -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Callable, Dict, List, Literal, Optional, Union from jupyter_client.blocking.client import BlockingKernelClient from jupyter_client.kernelspec import KernelSpec, KernelSpecManager @@ -314,7 +314,20 @@ def execute_code( session_id: str, code: str, exec_id: Optional[str] = None, + on_output: Optional[Callable[[str, str], None]] = None, ) -> ExecutionResult: + """Execute code in the given session. + + Args: + session_id: The session ID. + code: The code to execute. + exec_id: Optional execution ID (auto-generated if not provided). + on_output: Optional callback for streaming stdout/stderr during execution. + Signature: on_output(stream_name: str, text: str). + + Returns: + ExecutionResult with the execution outcome. + """ exec_id = get_id(prefix="exec") if exec_id is None else exec_id session = self._get_session(session_id) @@ -332,6 +345,7 @@ def execute_code( session.session_id, exec_id=exec_id, code=code, + on_output=on_output, ) exec_extra_result = self._execute_control_code_on_kernel( session.session_id, @@ -523,7 +537,24 @@ def _execute_code_on_kernel( silent: bool = False, store_history: bool = True, exec_type: ExecType = "user", + on_output: Optional[Callable[[str, str], None]] = None, ) -> EnvExecution: + """Execute code on the kernel and return the result. + + Args: + session_id: The session ID. + exec_id: The execution ID. + code: The code to execute. + silent: Whether to suppress output. + store_history: Whether to store the execution in history. + exec_type: The type of execution ("user" or "control"). + on_output: Optional callback called for each stdout/stderr chunk during execution. + Signature: on_output(stream_name: str, text: str) where stream_name is + "stdout" or "stderr". + + Returns: + EnvExecution with the execution result. + """ exec_result = EnvExecution(exec_id=exec_id, code=code, exec_type=exec_type) kc = self._get_client(session_id) result_msg_id = kc.execute( @@ -554,6 +585,10 @@ def _execute_code_on_kernel( stream_name = message["content"]["name"] stream_text = message["content"]["text"] + # Stream output callback for real-time updates + if on_output is not None: + on_output(stream_name, stream_text) + if stream_name == "stdout": exec_result.stdout.append(stream_text) elif stream_name == "stderr": diff --git a/taskweaver/ces/manager/sub_proc.py b/taskweaver/ces/manager/sub_proc.py index e7fa450d6..27ea9508b 100644 --- a/taskweaver/ces/manager/sub_proc.py +++ b/taskweaver/ces/manager/sub_proc.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from typing import Dict, Optional +from typing import Callable, Dict, Optional from taskweaver.ces.common import Client, ExecutionResult, KernelModeType, Manager @@ -47,8 +47,18 @@ def test_plugin(self, plugin_name: str) -> None: def update_session_var(self, session_var_dict: Dict[str, str]) -> None: self.mgr.env.update_session_var(self.session_id, session_var_dict) - def execute_code(self, exec_id: str, code: str) -> ExecutionResult: - return self.mgr.env.execute_code(self.session_id, code=code, exec_id=exec_id) + def execute_code( + self, + exec_id: str, + code: str, + on_output: Optional[Callable[[str, str], None]] = None, + ) -> ExecutionResult: + return self.mgr.env.execute_code( + self.session_id, + code=code, + exec_id=exec_id, + on_output=on_output, + ) class SubProcessManager(Manager): diff --git a/taskweaver/chat/console/chat.py b/taskweaver/chat/console/chat.py index b17622d12..3a5b410b4 100644 --- a/taskweaver/chat/console/chat.py +++ b/taskweaver/chat/console/chat.py @@ -101,7 +101,7 @@ def __init__(self): self.lock = threading.Lock() self.last_attachment_id = "" - self.pending_updates: List[Tuple[str, str]] = [] + self.pending_updates: List[Tuple[str, Any]] = [] # Handshake pair for pausing animation (e.g., during confirmation prompts) self.pause_animation = threading.Event() # Main requests pause @@ -188,6 +188,11 @@ def handle_post( elif type == PostEventType.post_status_update: with self.lock: self.pending_updates.append(("status_update", msg)) + elif type == PostEventType.post_execution_output: + with self.lock: + stream_name = extra["stream"] + text = extra["text"] + self.pending_updates.append(("execution_output", (stream_name, text))) def handle_message( self, @@ -461,6 +466,18 @@ def format_status_message(limit: int): error_message(opt) elif action == "status_update": status_msg = opt + elif action == "execution_output": + # Streaming output from plugin execution (print statements) + stream_name, text = opt + # Print inline with appropriate styling + prefix = " │ " if stream_name == "stdout" else " │! " + # Handle multi-line output + for line in text.splitlines(keepends=True): + line_text = line.rstrip("\n\r") + if line_text: # Only print non-empty lines + click.secho( + style_line(prefix) + style_msg(line_text), + ) self.pending_updates.clear() diff --git a/taskweaver/code_interpreter/code_executor.py b/taskweaver/code_interpreter/code_executor.py index f7859f0d5..499c2fbe8 100644 --- a/taskweaver/code_interpreter/code_executor.py +++ b/taskweaver/code_interpreter/code_executor.py @@ -1,6 +1,6 @@ import os from pathlib import Path -from typing import List, Literal, Optional +from typing import Callable, List, Literal, Optional from injector import inject @@ -67,7 +67,12 @@ def __init__( self.session_variables = {} @tracing_decorator - def execute_code(self, exec_id: str, code: str) -> ExecutionResult: + def execute_code( + self, + exec_id: str, + code: str, + on_output: Optional[Callable[[str, str], None]] = None, + ) -> ExecutionResult: with get_tracer().start_as_current_span("start"): self.start() @@ -81,7 +86,7 @@ def execute_code(self, exec_id: str, code: str) -> ExecutionResult: with get_tracer().start_as_current_span("run_code"): self.tracing.set_span_attribute("code", code) - result = self.exec_client.execute_code(exec_id, code) + result = self.exec_client.execute_code(exec_id, code, on_output=on_output) if result.is_success: for artifact in result.artifact: diff --git a/taskweaver/code_interpreter/code_interpreter/code_interpreter.py b/taskweaver/code_interpreter/code_interpreter/code_interpreter.py index a5adeaa2e..8422d7252 100644 --- a/taskweaver/code_interpreter/code_interpreter/code_interpreter.py +++ b/taskweaver/code_interpreter/code_interpreter/code_interpreter.py @@ -265,9 +265,13 @@ def reply( post_proxy.update_status("executing code") self.logger.info(f"Code to be executed: {executable_code}") + def on_execution_output(stream_name: str, text: str): + post_proxy.emit_execution_output(stream_name, text) + exec_result = self.executor.execute_code( exec_id=post_proxy.post.id, code=executable_code, + on_output=on_execution_output, ) if len(exec_result.variables) > 0: diff --git a/taskweaver/memory/AGENTS.md b/taskweaver/memory/AGENTS.md index 9cc5dc752..18bf81d06 100644 --- a/taskweaver/memory/AGENTS.md +++ b/taskweaver/memory/AGENTS.md @@ -55,26 +55,48 @@ class Post: ### AttachmentType (attachment.py) ```python -class AttachmentType(str, Enum): - # Planning +class AttachmentType(Enum): + # Planner plan = "plan" current_plan_step = "current_plan_step" + plan_reasoning = "plan_reasoning" + stop = "stop" - # Code execution - reply_content = "reply_content" # Generated code + # CodeInterpreter - code generation + thought = "thought" + reply_type = "reply_type" + reply_content = "reply_content" verification = "verification" + + # CodeInterpreter - execution + code_error = "code_error" execution_status = "execution_status" execution_result = "execution_result" - - # Control flow + artifact_paths = "artifact_paths" revise_message = "revise_message" - invalid_response = "invalid_response" + + # Function calling + function = "function" + + # WebExplorer + web_exploring_plan = "web_exploring_plan" + web_exploring_screenshot = "web_exploring_screenshot" + web_exploring_link = "web_exploring_link" # Shared state - shared_memory_entry = "shared_memory_entry" session_variables = "session_variables" + shared_memory_entry = "shared_memory_entry" + + # Misc + invalid_response = "invalid_response" + text = "text" + image_url = "image_url" ``` +### Backward Compatibility +`Attachment.from_dict()` returns `None` for removed types (e.g., `init_plan`). +`Post.from_dict()` filters out `None` attachments when loading old data. + ### SharedMemoryEntry (shared_memory_entry.py) Cross-role communication: ```python diff --git a/taskweaver/module/event_emitter.py b/taskweaver/module/event_emitter.py index df1629ba9..4d5e7d0fe 100644 --- a/taskweaver/module/event_emitter.py +++ b/taskweaver/module/event_emitter.py @@ -43,6 +43,7 @@ class PostEventType(Enum): post_attachment_update = "post_attachment_update" post_confirmation_request = "post_confirmation_request" post_confirmation_response = "post_confirmation_response" + post_execution_output = "post_execution_output" # Real-time stdout/stderr during code execution @dataclass @@ -235,6 +236,14 @@ def error(self, msg: str): self.post.message = msg self._emit(PostEventType.post_error, msg) + def emit_execution_output(self, stream_name: str, text: str): + """Emit real-time execution output (stdout/stderr) during code execution.""" + self._emit( + PostEventType.post_execution_output, + text, + {"stream": stream_name, "text": text}, + ) + def end(self, msg: str = ""): self._emit(PostEventType.post_end, msg) return self.post From 10c90d132bb0f0ad723a3ad84c029fd760fd6529 Mon Sep 17 00:00:00 2001 From: liqun Date: Tue, 27 Jan 2026 12:39:08 +0800 Subject: [PATCH 07/10] remove config --- project/taskweaver_config.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 project/taskweaver_config.json diff --git a/project/taskweaver_config.json b/project/taskweaver_config.json new file mode 100644 index 000000000..0f81d9e01 --- /dev/null +++ b/project/taskweaver_config.json @@ -0,0 +1,19 @@ +{ + "llm.azure_ad.aad_api_resource": "api://feb7b661-cac7-44a8-8dc1-163b63c23df2", + "llm.azure_ad.aad_api_scope": "openai", + "llm.azure_ad.aad_auth_mode": "default_azure_credential", + "llm.azure_ad.aad_tenant_id": "72f988bf-86f1-41af-91ab-2d7cd011db47", + "llm.azure_ad.aad_skip_interactive": false, + "llm.api_base": "https://cloudgpt-openai.azure-api.net", + "llm.api_type": "azure_ad", + "llm.model": "gpt-4.1-20250414", + "llm.response_format": "json_object", + "llm.azure_ad.max_tokens": 2048, + "session.roles": [ + "planner", + "code_interpreter", + "recepta" + ], + "session.max_internal_chat_round_num": 100, + "execution_service.kernel_mode": "local" +} \ No newline at end of file From bd7a7af882fc0e09bc790ab44f15ee547f3ff327 Mon Sep 17 00:00:00 2001 From: liqun Date: Tue, 27 Jan 2026 12:52:59 +0800 Subject: [PATCH 08/10] fix bug in long running tasks --- AGENTS.md | 2 +- README.md | 1 + taskweaver/ces/manager/defer.py | 9 +++++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3da2033ea..8bb0117f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md - TaskWeaver Development Guide -**Generated:** 2026-01-26 | **Commit:** 4a02444 | **Branch:** liqun/add_variables_to_code_generator +**Generated:** 2026-01-27 | **Commit:** 10c90d1 | **Branch:** liqun/add_variables_to_code_generator This document provides guidance for AI coding agents working on the TaskWeaver codebase. diff --git a/README.md b/README.md index ea1b5a2f2..b5755cc0f 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ We are looking forward to your contributions to make TaskWeaver better. - [ ] Better plugin experiences, such as displaying updates or stopping in the middle of running the plugin and user confirmation before running the plugin - [ ] Async interaction with LLMs - [ ] Support for remote code execution +- [ ] Better memory management ## ✨ Quick Start diff --git a/taskweaver/ces/manager/defer.py b/taskweaver/ces/manager/defer.py index 5b9fd0a07..7b8cecd07 100644 --- a/taskweaver/ces/manager/defer.py +++ b/taskweaver/ces/manager/defer.py @@ -83,8 +83,13 @@ def test_plugin(self, plugin_name: str) -> None: def update_session_var(self, session_var_dict: Dict[str, str]) -> None: self._get_proxy_client().update_session_var(session_var_dict) - def execute_code(self, exec_id: str, code: str) -> ExecutionResult: - return self._get_proxy_client().execute_code(exec_id, code) + def execute_code( + self, + exec_id: str, + code: str, + on_output: Optional[Callable[[str, str], None]] = None, + ) -> ExecutionResult: + return self._get_proxy_client().execute_code(exec_id, code, on_output=on_output) def _get_proxy_client(self) -> Client: return self._init_deferred_var()() From fffdf8698037ec5e758fe47f978898a1c494f22e Mon Sep 17 00:00:00 2001 From: liqun Date: Tue, 27 Jan 2026 18:47:16 +0800 Subject: [PATCH 09/10] refactor execution backend --- Dockerfile.executor | 61 ++ auto_eval/cases/rag/docs/code_execution.md | 38 +- docker-compose.yml | 75 ++ docs/design/prompt-compression.md | 213 ++++ docs/remote_execution.md | 322 ++++++ docs/specs/remote_execution_server_spec.md | 988 ++++++++++++++++++ project/taskweaver_config.json | 3 +- taskweaver/app/app.py | 75 +- taskweaver/ces/AGENTS.md | 299 +++++- taskweaver/ces/__init__.py | 68 +- taskweaver/ces/client/__init__.py | 13 + taskweaver/ces/client/execution_client.py | 444 ++++++++ taskweaver/ces/client/server_launcher.py | 415 ++++++++ taskweaver/ces/common.py | 1 + taskweaver/ces/manager/__init__.py | 23 + taskweaver/ces/manager/execution_service.py | 235 +++++ taskweaver/ces/server/__init__.py | 48 + taskweaver/ces/server/__main__.py | 138 +++ taskweaver/ces/server/app.py | 93 ++ taskweaver/ces/server/models.py | 216 ++++ taskweaver/ces/server/routes.py | 473 +++++++++ taskweaver/ces/server/session_manager.py | 380 +++++++ taskweaver/chat/console/chat.py | 9 +- taskweaver/code_interpreter/code_executor.py | 35 +- .../code_interpreter/code_interpreter.py | 19 +- .../code_interpreter_cli_only.py | 1 - .../code_interpreter_plugin_only.py | 8 - taskweaver/module/execution_service.py | 58 +- test-display-1_image.png | Bin 0 -> 68823 bytes test-exec-display-1_image.png | Bin 0 -> 68823 bytes tests/unit_tests/ces/test_execution_client.py | 615 +++++++++++ .../unit_tests/ces/test_execution_service.py | 552 ++++++++++ tests/unit_tests/ces/test_server_launcher.py | 596 +++++++++++ tests/unit_tests/ces/test_server_models.py | 428 ++++++++ tests/unit_tests/ces/test_session_manager.py | 521 +++++++++ website/docs/FAQ.md | 7 +- website/docs/code_execution.md | 46 +- website/docs/configurations/overview.md | 5 +- 38 files changed, 7361 insertions(+), 160 deletions(-) create mode 100644 Dockerfile.executor create mode 100644 docker-compose.yml create mode 100644 docs/design/prompt-compression.md create mode 100644 docs/remote_execution.md create mode 100644 docs/specs/remote_execution_server_spec.md create mode 100644 taskweaver/ces/client/__init__.py create mode 100644 taskweaver/ces/client/execution_client.py create mode 100644 taskweaver/ces/client/server_launcher.py create mode 100644 taskweaver/ces/manager/execution_service.py create mode 100644 taskweaver/ces/server/__init__.py create mode 100644 taskweaver/ces/server/__main__.py create mode 100644 taskweaver/ces/server/app.py create mode 100644 taskweaver/ces/server/models.py create mode 100644 taskweaver/ces/server/routes.py create mode 100644 taskweaver/ces/server/session_manager.py create mode 100644 test-display-1_image.png create mode 100644 test-exec-display-1_image.png create mode 100644 tests/unit_tests/ces/test_execution_client.py create mode 100644 tests/unit_tests/ces/test_execution_service.py create mode 100644 tests/unit_tests/ces/test_server_launcher.py create mode 100644 tests/unit_tests/ces/test_server_models.py create mode 100644 tests/unit_tests/ces/test_session_manager.py diff --git a/Dockerfile.executor b/Dockerfile.executor new file mode 100644 index 000000000..9ad001fc0 --- /dev/null +++ b/Dockerfile.executor @@ -0,0 +1,61 @@ +# Dockerfile.executor +# Docker image for TaskWeaver Code Execution Server +# +# Build: +# docker build -f Dockerfile.executor -t taskweaver-executor:latest . +# +# Run: +# docker run -p 8000:8000 -v $(pwd)/workspace:/app/workspace taskweaver-executor:latest +# +# With API key: +# docker run -p 8000:8000 -e TASKWEAVER_SERVER_API_KEY=your-secret-key taskweaver-executor:latest + +FROM python:3.10-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Install additional dependencies for the execution server +RUN pip install --no-cache-dir \ + fastapi \ + uvicorn[standard] \ + httpx \ + jupyter_client \ + ipykernel + +# Copy TaskWeaver package (only required modules for execution) +COPY taskweaver/ ./taskweaver/ + +# Create workspace directory for session data +RUN mkdir -p /app/workspace + +# Create non-root user for security +RUN useradd -m -s /bin/bash executor && \ + chown -R executor:executor /app +USER executor + +# Environment variables (can be overridden at runtime) +ENV TASKWEAVER_SERVER_HOST=0.0.0.0 +ENV TASKWEAVER_SERVER_PORT=8000 +ENV TASKWEAVER_SERVER_WORK_DIR=/app/workspace +ENV PYTHONPATH="/app" +ENV PYTHONUNBUFFERED=1 + +# Expose the server port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import httpx; httpx.get('http://localhost:8000/api/v1/health').raise_for_status()" || exit 1 + +# Run the execution server +CMD ["python", "-m", "taskweaver.ces.server", "--host", "0.0.0.0", "--port", "8000"] diff --git a/auto_eval/cases/rag/docs/code_execution.md b/auto_eval/cases/rag/docs/code_execution.md index e572bd82f..604cdceea 100644 --- a/auto_eval/cases/rag/docs/code_execution.md +++ b/auto_eval/cases/rag/docs/code_execution.md @@ -1,39 +1,41 @@ # Code Execution ->💡We have set the `container` mode as default for code execution, especially when the usage of the agent -is open to untrusted users. Refer to [Docker Security](https://docs.docker.com/engine/security/) for better understanding -of the security features of Docker. To opt for the `local` mode, you need to explicitly set the `execution_service.kernel_mode` -parameter in the `taskweaver_config.json` file to `local`. +>💡TaskWeaver uses a **server-based architecture** for code execution. By default, the server auto-starts locally. +>For isolated environments, you can run the server in a Docker container by setting `execution_service.server.container` to `true`. +>Refer to [Docker Security](https://docs.docker.com/engine/security/) for better understanding of the security features of Docker. TaskWeaver is a code-first agent framework, which means that it always converts the user request into code and executes the code to generate the response. In our current implementation, we use a Jupyter Kernel to execute the code. We choose Jupyter Kernel because it is a well-established tool for interactive computing, and it supports many programming languages. -## Two Modes of Code Execution +## Execution Server Architecture -TaskWeaver supports two modes of code execution: `local` and `container`. -The `container` mode is the default mode. The key difference between the two modes is that the `container` mode -executes the code inside a Docker container, which provides a more secure environment for code execution, while -the `local` mode executes the code as a subprocess of the TaskWeaver process. -As a result, in the `local` mode, if the user has malicious intent, the user could potentially -instruct TaskWeaver to execute harmful code on the host machine. In addition, the LLM could also generate -harmful code, leading to potential security risks. +TaskWeaver uses an HTTP-based execution server that wraps the Jupyter kernel: + +- **Local mode (default)**: Server auto-starts as a subprocess with full filesystem access +- **Container mode**: Server runs in Docker for security isolation + +The key difference between local and container modes is that container mode executes code inside a Docker container, +which provides a more secure environment. ## How to Configure the Code Execution Mode -To configure the code execution mode, you need to set the `execution_service.kernel_mode` parameter in the -`taskweaver_config.json` file. The value of the parameter could be `local` or `container`. The default value -is `container`. +To run the execution server in a Docker container, set the `execution_service.server.container` parameter in the +`taskweaver_config.json` file to `true`. By default, the server runs locally as a subprocess. + +```json +{ + "execution_service.server.container": true +} +``` -TaskWeaver supports the `local` mode without any additional setup. However, to use the `container` mode, -there are a few prerequisites: +To use container mode, there are a few prerequisites: - Docker is installed on the host machine. - A Docker image is built and available on the host machine for code execution. -- The `execution_service.kernel_mode` parameter is set to `container` in the `taskweaver_config.json` file. Once the code repository is cloned to your local machine, you can build the Docker image by running the following command in the root directory of the code repository: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..e9f0e12fc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,75 @@ +# Docker Compose configuration for TaskWeaver Code Execution Server +# +# Usage: +# # Start the executor service +# docker-compose up -d executor +# +# # View logs +# docker-compose logs -f executor +# +# # Stop the service +# docker-compose down +# +# Environment variables (create .env file or set in environment): +# API_KEY - API key for authentication (optional for localhost) +# SESSION_TIMEOUT - Session timeout in seconds (default: 3600) + +version: '3.8' + +# Note: GPU profile removed. The executor is CPU-only by default and does not +# include NVIDIA runtime or GPU reservations. If GPU support is needed, add a +# dedicated profile with the appropriate device reservations for your platform. + +services: + executor: + build: + context: . + dockerfile: Dockerfile.executor + image: taskweaver-executor:latest + container_name: taskweaver-executor + ports: + - "8000:8000" + volumes: + # Mount workspace for persistent session data and artifacts + - ./workspace:/app/workspace + # Optional: Mount custom plugins + # - ./project/plugins:/app/plugins:ro + environment: + - TASKWEAVER_SERVER_API_KEY=${API_KEY:-} + - TASKWEAVER_SERVER_SESSION_TIMEOUT=${SESSION_TIMEOUT:-3600} + - TASKWEAVER_SERVER_HOST=0.0.0.0 + - TASKWEAVER_SERVER_PORT=8000 + - TASKWEAVER_SERVER_WORK_DIR=/app/workspace + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/api/v1/health').raise_for_status()"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # Development configuration with live reload + executor-dev: + build: + context: . + dockerfile: Dockerfile.executor + image: taskweaver-executor:dev + container_name: taskweaver-executor-dev + ports: + - "8001:8000" + volumes: + # Mount source code for development + - ./taskweaver:/app/taskweaver:ro + - ./workspace:/app/workspace + environment: + - TASKWEAVER_SERVER_API_KEY=${API_KEY:-dev-key} + - TASKWEAVER_SERVER_SESSION_TIMEOUT=7200 + - TASKWEAVER_SERVER_HOST=0.0.0.0 + - TASKWEAVER_SERVER_PORT=8000 + profiles: + - dev + restart: unless-stopped + +volumes: + workspace: + driver: local diff --git a/docs/design/prompt-compression.md b/docs/design/prompt-compression.md new file mode 100644 index 000000000..f74bbbc52 --- /dev/null +++ b/docs/design/prompt-compression.md @@ -0,0 +1,213 @@ +# Prompt Compression + +## Problem +After multiple conversation rounds, chat history grows long—especially with code and execution results. This risks exceeding LLM context windows and degrading response quality due to attention dilution over lengthy contexts. + +## Goals +- Keep prompt size bounded regardless of conversation length. +- Preserve essential context: what user requested, what was executed, what variables exist. +- Maintain code generation correctness by not losing intermediate state references. +- Minimize additional latency and cost from compression overhead. + +## Non-Goals +- Real-time streaming compression during generation. +- Vector DB retrieval for selective history (breaks code continuity). +- Cross-session memory persistence. + +## Design Overview + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Conversation │ +├─────────────────────────────────────────────────────────────────┤ +│ ConversationSummary │ Round(n-4) │ Round(n-3) │ ... │ Round(n)│ +│ (compressed history) │←─ compress ─→│←────── retain ──────────→│ +└─────────────────────────────────────────────────────────────────┘ +``` + +1. **Trigger Condition**: When total rounds reach `rounds_to_compress + rounds_to_retain` +2. **Compression**: Oldest `rounds_to_compress` rounds are summarized via LLM +3. **Retention**: Latest `rounds_to_retain` rounds kept verbatim for context continuity +4. **Accumulation**: Next compression merges new rounds with existing summary + +### Components + +**RoundCompressor** (`taskweaver/memory/compression.py`) +- Tracks processed rounds to avoid re-compression +- Maintains rolling `previous_summary` state +- Uses configurable LLM (can differ from main model via `llm_alias`) + +**Prompt Templates** +- Planner: `taskweaver/planner/compression_prompt.yaml` + - Focuses on plan steps and execution status + - Output: `{"ConversationSummary": "..."}` + +- Code Generator: `taskweaver/code_interpreter/code_interpreter/compression_prompt.yaml` + - Tracks conversation + variable definitions + - Output: `{"ConversationSummary": "...", "Variables": [...]}` + +### Data Flow + +``` +compress_rounds(rounds, formatter, template) + │ + ├─► Check: remaining_rounds < threshold? → return previous_summary + all rounds + │ + ├─► Extract rounds to compress (oldest N) + │ + ├─► _summarize() + │ ├─► Format rounds via rounds_formatter + │ ├─► Build prompt with PREVIOUS_SUMMARY + │ ├─► LLM call → new_summary + │ └─► Update processed_rounds set + │ + └─► Return (new_summary, retained_rounds) +``` + +## Configuration + +| Key | Default | Description | +|-----|---------|-------------| +| `round_compressor.rounds_to_compress` | 2 | Rounds summarized per compression cycle | +| `round_compressor.rounds_to_retain` | 3 | Recent rounds kept verbatim | +| `round_compressor.llm_alias` | "" | Optional separate LLM for compression | +| `planner.prompt_compression` | false | Enable for Planner role | +| `code_generator.prompt_compression` | false | Enable for CodeGenerator role | + +## Files Touched +- `taskweaver/memory/compression.py` — RoundCompressor implementation +- `taskweaver/planner/compression_prompt.yaml` — Planner summary template +- `taskweaver/code_interpreter/code_interpreter/compression_prompt.yaml` — CodeGen summary template +- `taskweaver/planner/planner.py` — Integration with Planner role +- `taskweaver/code_interpreter/code_interpreter/code_generator.py` — Integration with CodeGenerator + +## Rationale +- **Why summarization over retrieval?** Code generation requires continuous context—skipping intermediate code/results breaks execution state references. +- **Why separate templates?** Planner needs plan-centric summaries; CodeGenerator needs variable tracking for reuse. +- **Why configurable LLM?** Compression can use cheaper/faster models since it's less critical than main generation. + +## Risks / Mitigations +| Risk | Mitigation | +|------|------------| +| Summary loses critical details | Variables explicitly tracked; recent rounds retained verbatim | +| Additional latency per compression | Triggered infrequently (every N rounds); can use faster LLM | +| Cost overhead | Compression prompts are small; configurable cheaper model | +| Summary quality degrades over time | Rolling summary preserves cumulative context | + +--- + +## Future Improvements + +### 1. Adaptive Compression Threshold +**Current**: Fixed `rounds_to_compress` + `rounds_to_retain` trigger. + +**Improvement**: Token-based triggering instead of round-based. +```python +# Trigger when estimated prompt exceeds threshold +if estimate_tokens(rounds) > max_prompt_tokens * 0.8: + compress() +``` +**Benefit**: Adapts to variable round sizes (some rounds have large outputs, others minimal). + +### 2. Hierarchical Summarization +**Current**: Single-level rolling summary. + +**Improvement**: Multi-level summaries for very long conversations. +``` +Level 0: Recent rounds (verbatim) +Level 1: Summary of last ~10 rounds +Level 2: Summary of last ~50 rounds (summary of summaries) +``` +**Benefit**: Better preservation of older but important context in extended sessions. + +### 3. Selective Compression +**Current**: All rounds in compression window treated equally. + +**Improvement**: Importance-weighted compression. +- Preserve rounds with errors/retries (learning signal) +- Preserve rounds with new variable definitions +- Aggressively compress successful single-shot rounds + +**Benefit**: Retains debugging-relevant context; reduces noise from routine operations. + +### 4. Incremental Variable Tracking +**Current**: Variables tracked in compression summary only. + +**Improvement**: Maintain separate, always-current variable registry. +```python +class VariableRegistry: + def update_from_execution(self, result: ExecutionResult): + # Track: name, type, shape/size, last_modified_round + pass + + def get_relevant_vars(self, query: str) -> List[VarInfo]: + # Semantic similarity to current request + pass +``` +**Benefit**: More accurate variable availability; supports "which variables can I use?" queries. + +### 5. Compression Quality Validation +**Current**: No validation of summary quality. + +**Improvement**: Automated checks before accepting summary. +- Verify mentioned variables actually exist +- Check summary length is within bounds +- Validate JSON structure +- Optional: LLM self-consistency check + +**Benefit**: Prevents corrupted summaries from propagating. + +### 6. Async/Background Compression +**Current**: Synchronous compression blocks response generation. + +**Improvement**: Compress in background after response sent. +```python +async def reply(...): + response = await generate_response(recent_rounds, previous_summary) + # Fire-and-forget compression for next turn + asyncio.create_task(compress_if_needed(rounds)) + return response +``` +**Benefit**: Removes compression latency from user-perceived response time. + +### 7. Compression Caching +**Current**: Re-summarizes if compression fails or context changes. + +**Improvement**: Cache compression results keyed by round IDs. +```python +cache_key = hash(tuple(r.id for r in rounds_to_compress)) +if cache_key in compression_cache: + return compression_cache[cache_key] +``` +**Benefit**: Avoids redundant LLM calls on retries or re-runs. + +### 8. Domain-Specific Compression Templates +**Current**: Generic templates for Planner and CodeGenerator. + +**Improvement**: Task-type aware templates. +- Data analysis: Emphasize DataFrame schemas, column names +- Visualization: Track figure types, customizations applied +- ML workflows: Preserve model configs, metric history + +**Benefit**: Higher-fidelity summaries for specialized use cases. + +### 9. User-Controllable Compression +**Current**: Fully automatic, user has no visibility. + +**Improvement**: Expose compression to users. +- Show "[Summarized N rounds]" indicator in UI +- Allow "expand summary" to see what was compressed +- Let user mark rounds as "important—don't compress" + +**Benefit**: Transparency; user control over context preservation. + +### 10. Streaming-Compatible Compression +**Current**: Operates on complete rounds only. + +**Improvement**: Progressive compression during long outputs. +- Compress stdout/stderr streams in real-time +- Summarize partial results before full execution completes + +**Benefit**: Keeps prompts bounded even during long-running executions. diff --git a/docs/remote_execution.md b/docs/remote_execution.md new file mode 100644 index 000000000..d644383a3 --- /dev/null +++ b/docs/remote_execution.md @@ -0,0 +1,322 @@ +# Code Execution Server + +TaskWeaver uses a **server-based architecture** for code execution, providing secure, scalable, and flexible deployment options. + +## Overview + +The execution server provides an HTTP API that wraps TaskWeaver's Jupyter kernel: + +- **Local mode**: Server auto-starts as a subprocess (default) +- **Container mode**: Server runs in Docker for isolation +- **Remote mode**: Connect to a pre-deployed server for GPU access or shared resources + +``` +┌─────────────────────┐ ┌─────────────────────────────────┐ +│ TaskWeaver Client │ HTTP │ Execution Server │ +│ │◄───────▶│ │ +│ - CodeInterpreter │ │ - FastAPI application │ +│ - ExecutionClient │ │ - Jupyter kernel management │ +│ │ │ - Session isolation │ +└─────────────────────┘ └─────────────────────────────────┘ +``` + +## Quick Start + +### Default Configuration (Local Auto-Start) + +No configuration needed. TaskWeaver automatically starts a local execution server: + +```bash +python -m taskweaver -p ./project/ +``` + +### Container Mode + +Run the execution server in Docker for isolation: + +```json +{ + "execution.server.container": true +} +``` + +### Remote Server + +Connect to a pre-deployed execution server: + +```json +{ + "execution.server.url": "http://192.168.1.100:8000", + "execution.server.api_key": "your-secret-key", + "execution.server.auto_start": false +} +``` + +## Configuration Reference + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `execution.server.url` | string | `"http://localhost:8000"` | Server URL | +| `execution.server.api_key` | string | `""` | API key for authentication | +| `execution.server.auto_start` | bool | `true` | Auto-start server if not running | +| `execution.server.container` | bool | `false` | Run server in Docker container | +| `execution.server.container_image` | string | `"taskweavercontainers/taskweaver-executor:latest"` | Docker image | +| `execution.server.host` | string | `"localhost"` | Server bind host | +| `execution.server.port` | int | `8000` | Server bind port | +| `execution.server.timeout` | int | `300` | Request timeout (seconds) | + +## Deployment Options + +### Option 1: Local Process (Development) + +Best for development and single-user scenarios: + +```bash +# TaskWeaver auto-starts the server - no manual steps needed +python -m taskweaver -p ./project/ +``` + +Or start the server manually: + +```bash +python -m taskweaver.ces.server \ + --host localhost \ + --port 8000 \ + --work-dir ./workspace +``` + +### Option 2: Docker Container (Isolation) + +Best for security isolation and reproducible environments: + +```bash +# Build the image +docker build -f Dockerfile.executor -t taskweaver-executor:latest . + +# Run the container +docker run -d \ + -p 8000:8000 \ + -v $(pwd)/workspace:/app/workspace \ + -e TASKWEAVER_SERVER_API_KEY=your-secret-key \ + taskweaver-executor:latest +``` + +Or use Docker Compose: + +```bash +# Start the executor service +docker-compose up -d executor + +# View logs +docker-compose logs -f executor + +# Stop +docker-compose down +``` + +### Option 3: Remote Server (Production) + +Best for team environments, GPU access, or shared resources: + +1. **Deploy the server** on a remote machine: + +```bash +# On the remote server +docker-compose up -d executor +``` + +2. **Configure TaskWeaver** to connect: + +```json +{ + "execution.server.url": "http://your-server:8000", + "execution.server.api_key": "your-secret-key", + "execution.server.auto_start": false +} +``` + +## API Reference + +The execution server exposes a REST API at `/api/v1/`: + +### Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/health` | Health check (no auth required) | +| `POST` | `/sessions` | Create new execution session | +| `DELETE` | `/sessions/{session_id}` | Stop and remove session | +| `GET` | `/sessions/{session_id}` | Get session info | +| `POST` | `/sessions/{session_id}/plugins` | Load a plugin | +| `POST` | `/sessions/{session_id}/execute` | Execute code | +| `GET` | `/sessions/{session_id}/execute/{exec_id}/stream` | Stream output (SSE) | +| `POST` | `/sessions/{session_id}/variables` | Update session variables | +| `GET` | `/sessions/{session_id}/artifacts/{filename}` | Download artifact | + +### Example: Execute Code + +```bash +# Create a session +curl -X POST http://localhost:8000/api/v1/sessions \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-key" \ + -d '{"session_id": "my-session"}' + +# Execute code +curl -X POST http://localhost:8000/api/v1/sessions/my-session/execute \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-key" \ + -d '{ + "exec_id": "exec-001", + "code": "import pandas as pd\ndf = pd.DataFrame({\"a\": [1, 2, 3]})\ndf" + }' +``` + +### Example: Streaming Execution + +```bash +# Execute with streaming +curl -X POST http://localhost:8000/api/v1/sessions/my-session/execute \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-key" \ + -d '{"exec_id": "exec-002", "code": "for i in range(5): print(i)", "stream": true}' + +# Connect to SSE stream +curl -N http://localhost:8000/api/v1/sessions/my-session/execute/exec-002/stream \ + -H "X-API-Key: your-key" +``` + +## Security Considerations + +### Authentication + +- **API Key**: Set `TASKWEAVER_SERVER_API_KEY` environment variable +- **Localhost bypass**: API key optional for localhost connections +- **Header**: Use `X-API-Key` header for authentication + +### Container Isolation + +When running in container mode: + +- Code executes in isolated container +- Filesystem access limited to mounted volumes +- Network access can be restricted via Docker networking + +### Best Practices + +1. **Always use API keys** in production deployments +2. **Run as non-root user** (Dockerfile.executor does this by default) +3. **Limit mounted volumes** to only required directories +4. **Use HTTPS** with a reverse proxy for remote deployments +5. **Set session timeouts** to clean up idle sessions + +## GPU Support + +For GPU-enabled deployments: + +```bash +# Using Docker Compose with GPU profile +docker-compose --profile gpu up -d executor-gpu +``` + +Or manually: + +```bash +docker run -d \ + --gpus all \ + -p 8000:8000 \ + -v $(pwd)/workspace:/app/workspace \ + -e TASKWEAVER_SERVER_API_KEY=your-key \ + taskweaver-executor:latest +``` + +## Troubleshooting + +### Server Won't Start + +1. **Port in use**: Check if port 8000 is available + ```bash + lsof -i :8000 + ``` + +2. **Missing dependencies**: Install required packages + ```bash + pip install fastapi uvicorn httpx jupyter_client ipykernel + ``` + +3. **Permission denied**: Ensure workspace directory is writable + +### Connection Refused + +1. **Server not running**: Start the server manually to see errors + ```bash + python -m taskweaver.ces.server --host 0.0.0.0 --port 8000 + ``` + +2. **Firewall blocking**: Check firewall rules for port 8000 + +3. **Wrong URL**: Verify `execution.server.url` in configuration + +### Authentication Errors + +1. **Missing API key**: Ensure `X-API-Key` header is set +2. **Wrong API key**: Verify key matches server configuration +3. **Localhost bypass**: API key may be optional for localhost + +### Execution Timeouts + +1. **Increase timeout**: Set `execution.server.timeout` to a higher value +2. **Use streaming**: Enable `stream: true` for long-running code +3. **Check server resources**: Ensure server has sufficient CPU/memory + +### Container Issues + +1. **Docker not running**: Start Docker daemon +2. **Image not found**: Build or pull the image + ```bash + docker build -f Dockerfile.executor -t taskweaver-executor:latest . + ``` +3. **Volume permissions**: Ensure mounted directories have correct permissions + +## Monitoring + +### Health Check + +```bash +curl http://localhost:8000/api/v1/health +``` + +Response: +```json +{ + "status": "healthy", + "version": "0.1.0", + "active_sessions": 2 +} +``` + +### Session Info + +```bash +curl http://localhost:8000/api/v1/sessions/my-session \ + -H "X-API-Key: your-key" +``` + +Response: +```json +{ + "session_id": "my-session", + "status": "running", + "created_at": "2024-01-15T10:30:00Z", + "last_activity": "2024-01-15T11:45:30Z", + "loaded_plugins": ["sql_pull_data"], + "execution_count": 15, + "cwd": "/app/workspace/my-session" +} +``` + +## Architecture Details + +For developers and contributors, see the technical specification at: +- [Remote Execution Server Spec](./specs/remote_execution_server_spec.md) +- [CES Module Documentation](../taskweaver/ces/AGENTS.md) diff --git a/docs/specs/remote_execution_server_spec.md b/docs/specs/remote_execution_server_spec.md new file mode 100644 index 000000000..bc693a380 --- /dev/null +++ b/docs/specs/remote_execution_server_spec.md @@ -0,0 +1,988 @@ +# Technical Specification: Server-First Execution Architecture + +**Version:** 1.0 +**Date:** 2026-01-27 +**Status:** Approved for Implementation + +## 1. Overview + +This specification describes a server-first architecture for TaskWeaver's code execution system. The execution server provides an HTTP API that wraps the Jupyter kernel, enabling both local and remote execution through a unified interface. + +## 2. Design Principles + +1. **Server-Only**: All execution goes through the HTTP API +2. **Local by Default**: Server auto-starts locally, providing full filesystem access +3. **Container as Wrapper**: Container mode wraps the entire server, not just the kernel +4. **Minimal Duplication**: Server reuses existing `Environment` class internally + +--- + +## 3. Architecture + +### 3.1 High-Level Component Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ TASKWEAVER CLIENT │ +│ │ +│ ┌─────────────────┐ ┌──────────────────┐ ┌───────────────────────────┐ │ +│ │ CodeInterpreter │───▶│ CodeExecutor │───▶│ ExecutionServiceProvider│ │ +│ └─────────────────┘ └──────────────────┘ └─────────────┬─────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────┐ │ +│ │ ExecutionClient │ │ +│ │ (HTTP) │ │ +│ └─────────┬─────────┘ │ +│ │ │ +└───────────────────────────────────┼─────────────────────────────────────────────┘ + │ + │ HTTP (localhost:8000 or remote) + ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ EXECUTION SERVER │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ FastAPI Application │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────────┐ │ │ +│ │ │ /sessions │ │ /plugins │ │ /execute │ │ /artifacts │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ └───────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ ServerSessionManager │ │ +│ │ │ │ +│ │ sessions: Dict[session_id, ServerSession] │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ ServerSession │ │ │ +│ │ │ - session_id: str │ │ │ +│ │ │ - environment: Environment (EnvMode.Local) │ │ │ +│ │ │ - created_at: datetime │ │ │ +│ │ │ - last_activity: datetime │ │ │ +│ │ └─────────────────────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ Environment (Existing Code) │ │ +│ │ │ │ +│ │ - EnvMode.Local only (container isolation at server level) │ │ +│ │ - Jupyter kernel management │ │ +│ │ - Plugin loading via magic commands │ │ +│ │ - Code execution │ │ +│ └─────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 Deployment Configurations + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ DEPLOYMENT A: Local Process (Default) │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ User's Machine │ │ +│ │ │ │ +│ │ ┌─────────────────┐ ┌─────────────────────────────────┐ │ │ +│ │ │ TaskWeaver │ HTTP │ Execution Server (subprocess) │ │ │ +│ │ │ Main Process │◄────────────▶│ │ │ │ +│ │ │ │ localhost │ - Full filesystem access │ │ │ +│ │ │ (auto-starts │ :8000 │ - Local Python packages │ │ │ +│ │ │ server) │ │ - User's environment vars │ │ │ +│ │ └─────────────────┘ └─────────────────────────────────┘ │ │ +│ │ │ │ +│ │ Config: auto_start=true, container=false │ │ +│ └─────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ DEPLOYMENT B: Local Container │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ User's Machine │ │ +│ │ │ │ +│ │ ┌─────────────────┐ ┌─────────────────────────────────┐ │ │ +│ │ │ TaskWeaver │ HTTP │ Docker Container │ │ │ +│ │ │ Main Process │◄────────────▶│ ┌─────────────────────────────┐ │ │ │ +│ │ │ │ localhost │ │ Execution Server │ │ │ │ +│ │ │ (auto-starts │ :8000 │ │ │ │ │ │ +│ │ │ container) │ │ │ - Isolated filesystem │ │ │ │ +│ │ └─────────────────┘ │ │ - Controlled packages │ │ │ │ +│ │ │ └─────────────────────────────┘ │ │ │ +│ │ │ │ │ │ +│ │ │ Volumes: │ │ │ +│ │ │ - ./workspace:/app/workspace │ │ │ +│ │ └─────────────────────────────────┘ │ │ +│ │ │ │ +│ │ Config: auto_start=true, container=true │ │ +│ └─────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ DEPLOYMENT C: Remote Server │ +│ │ +│ ┌──────────────────────────────┐ ┌──────────────────────────────────────┐ │ +│ │ User's Machine │ │ Remote Machine │ │ +│ │ │ │ │ │ +│ │ ┌────────────────────────┐ │HTTP│ ┌────────────────────────────────┐ │ │ +│ │ │ TaskWeaver │ │ │ │ Execution Server │ │ │ +│ │ │ Main Process │◄─┼────┼─▶│ │ │ │ +│ │ │ │ │ │ │ - Server's filesystem │ │ │ +│ │ │ (connects to remote) │ │ │ │ - Server's packages │ │ │ +│ │ └────────────────────────┘ │ │ │ - GPU access (if available) │ │ │ +│ │ │ │ └────────────────────────────────┘ │ │ +│ └──────────────────────────────┘ └──────────────────────────────────────┘ │ +│ │ +│ Config: auto_start=false, url="http://remote:8000", api_key="xxx" │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. File Structure + +### 4.1 New Files + +``` +taskweaver/ces/ +├── __init__.py # Update exports +├── common.py # Existing - no changes needed +├── environment.py # Existing - no changes needed +│ +├── manager/ +│ ├── __init__.py # Update exports +│ ├── sub_proc.py # Internal - used by server for kernel management +│ └── execution_service.py # NEW - ExecutionServiceProvider +│ +├── server/ # NEW - Server package +│ ├── __init__.py +│ ├── app.py # FastAPI application +│ ├── routes.py # API route handlers +│ ├── session_manager.py # Server-side session management +│ ├── models.py # Pydantic request/response models +│ └── __main__.py # CLI entry point: python -m taskweaver.ces.server +│ +├── client/ # NEW - Client package +│ ├── __init__.py +│ ├── execution_client.py # HTTP client implementation +│ └── server_launcher.py # Auto-start server process/container +│ +├── kernel/ # Existing - no changes +└── runtime/ # Existing - no changes +``` + +### 4.2 Modified Files + +``` +taskweaver/ +├── config/ +│ └── config_mgt.py # Add execution server config options +│ +├── code_interpreter/ +│ └── code_executor.py # Update to use ExecutionServiceProvider +│ +└── app/ + └── app.py # Wire up new execution service +``` + +--- + +## 5. Configuration + +### 5.1 Configuration Schema + +```python +# New configuration options in config_mgt.py + +class ExecutionConfig(ModuleConfig): + def _configure(self) -> None: + self._set_name("execution") + + # Server configuration + self.server_url = self._get_str("server.url", "http://localhost:8000") + self.server_api_key = self._get_str("server.api_key", "") + self.server_auto_start = self._get_bool("server.auto_start", True) + self.server_container = self._get_bool("server.container", False) + self.server_container_image = self._get_str( + "server.container_image", + "taskweavercontainers/taskweaver-executor:latest" + ) + self.server_host = self._get_str("server.host", "localhost") + self.server_port = self._get_int("server.port", 8000) + self.server_timeout = self._get_int("server.timeout", 300) +``` + +### 5.2 Configuration Examples + +```json +// Example 1: Default - Local auto-started server +{ + // No configuration needed, uses defaults +} + +// Example 2: Local container +{ + "execution.server.container": true +} + +// Example 3: Remote server +{ + "execution.server.url": "http://192.168.1.100:8000", + "execution.server.api_key": "your-secret-key", + "execution.server.auto_start": false +} +``` + +--- + +## 6. API Specification + +### 6.1 Base URL and Authentication + +``` +Base URL: http://{host}:{port}/api/v1 +Authentication: X-API-Key header (optional for localhost) +``` + +### 6.2 Endpoints Summary + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/health` | Health check (no auth required) | +| POST | `/sessions` | Create new session | +| DELETE | `/sessions/{session_id}` | Stop and remove session | +| GET | `/sessions/{session_id}` | Get session info | +| POST | `/sessions/{session_id}/plugins` | Load plugin | +| POST | `/sessions/{session_id}/execute` | Execute code | +| GET | `/sessions/{session_id}/execute/{exec_id}/stream` | Stream execution output (SSE) | +| POST | `/sessions/{session_id}/variables` | Update session variables | +| GET | `/sessions/{session_id}/artifacts/{filename}` | Download artifact file | + +### 6.3 Endpoint Details + +#### 6.3.1 Health Check + +``` +GET /api/v1/health + +Response 200: +{ + "status": "healthy", + "version": "0.1.0", + "active_sessions": 2 +} +``` + +#### 6.3.2 Create Session + +``` +POST /api/v1/sessions +Content-Type: application/json +X-API-Key: {api_key} + +Request: +{ + "session_id": "session-abc123", + "cwd": "/optional/working/directory" // Optional, server decides default +} + +Response 201: +{ + "session_id": "session-abc123", + "status": "created", + "cwd": "/actual/working/directory" +} + +Response 409 (session exists): +{ + "detail": "Session session-abc123 already exists" +} +``` + +#### 6.3.3 Stop Session + +``` +DELETE /api/v1/sessions/{session_id} +X-API-Key: {api_key} + +Response 200: +{ + "session_id": "session-abc123", + "status": "stopped" +} + +Response 404: +{ + "detail": "Session session-abc123 not found" +} +``` + +#### 6.3.4 Get Session Info + +``` +GET /api/v1/sessions/{session_id} +X-API-Key: {api_key} + +Response 200: +{ + "session_id": "session-abc123", + "status": "running", + "created_at": "2024-01-15T10:30:00Z", + "last_activity": "2024-01-15T11:45:30Z", + "loaded_plugins": ["sql_pull_data", "anomaly_detection"], + "execution_count": 15, + "cwd": "/path/to/working/directory" +} +``` + +#### 6.3.5 Load Plugin + +``` +POST /api/v1/sessions/{session_id}/plugins +Content-Type: application/json +X-API-Key: {api_key} + +Request: +{ + "name": "sql_pull_data", + "code": "from taskweaver.plugin import register_plugin\n\n@register_plugin\nclass SqlPullData:\n def __call__(self, query: str):\n ...", + "config": { + "api_type": "openai", + "api_key": "sk-xxxxx", + "sqlite_db_path": "sqlite:///data/mydb.db" + } +} + +Response 200: +{ + "name": "sql_pull_data", + "status": "loaded" +} + +Response 400 (load error): +{ + "detail": "Failed to load plugin sql_pull_data: SyntaxError at line 15" +} +``` + +#### 6.3.6 Execute Code + +``` +POST /api/v1/sessions/{session_id}/execute +Content-Type: application/json +X-API-Key: {api_key} + +Request: +{ + "exec_id": "exec-001", + "code": "import pandas as pd\ndf = pd.DataFrame({'a': [1, 2, 3]})\nprint('Created DataFrame')\ndf", + "stream": false // Optional, default false +} + +Response 200 (stream=false): +{ + "execution_id": "exec-001", + "is_success": true, + "error": null, + "output": " a\n0 1\n1 2\n2 3", + "stdout": ["Created DataFrame\n"], + "stderr": [], + "log": [], + "artifact": [], + "variables": [ + ["df", "DataFrame(3 rows × 1 columns)"] + ] +} + +Response 202 (stream=true): +{ + "execution_id": "exec-001", + "stream_url": "/api/v1/sessions/session-abc123/execute/exec-001/stream" +} +``` + +#### 6.3.7 Stream Execution Output + +``` +GET /api/v1/sessions/{session_id}/execute/{exec_id}/stream +Accept: text/event-stream +X-API-Key: {api_key} + +Response (SSE stream): +event: output +data: {"type": "stdout", "text": "Processing step 1...\n"} + +event: output +data: {"type": "stdout", "text": "Processing step 2...\n"} + +event: output +data: {"type": "stderr", "text": "Warning: deprecated function\n"} + +event: result +data: {"execution_id": "exec-001", "is_success": true, "output": "Done", ...} + +event: done +data: {} +``` + +#### 6.3.8 Update Session Variables + +``` +POST /api/v1/sessions/{session_id}/variables +Content-Type: application/json +X-API-Key: {api_key} + +Request: +{ + "variables": { + "user_name": "Alice", + "project_id": "proj-123" + } +} + +Response 200: +{ + "status": "updated", + "variables": { + "user_name": "Alice", + "project_id": "proj-123" + } +} +``` + +#### 6.3.9 Download Artifact + +``` +GET /api/v1/sessions/{session_id}/artifacts/{filename} +X-API-Key: {api_key} + +Response 200: +Content-Type: application/octet-stream (or appropriate mime type) +Content-Disposition: attachment; filename="chart.png" + + + +Response 404: +{ + "detail": "Artifact chart.png not found" +} +``` + +--- + +## 7. Data Models + +### 7.1 Request Models + +```python +# taskweaver/ces/server/models.py + +from pydantic import BaseModel, Field +from typing import Any, Dict, List, Optional + +class CreateSessionRequest(BaseModel): + session_id: str = Field(..., description="Unique session identifier") + cwd: Optional[str] = Field(None, description="Working directory for code execution") + +class LoadPluginRequest(BaseModel): + name: str = Field(..., description="Plugin name") + code: str = Field(..., description="Plugin source code") + config: Dict[str, Any] = Field(default_factory=dict, description="Plugin configuration") + +class ExecuteCodeRequest(BaseModel): + exec_id: str = Field(..., description="Unique execution identifier") + code: str = Field(..., description="Python code to execute") + stream: bool = Field(False, description="Enable streaming output") + +class UpdateVariablesRequest(BaseModel): + variables: Dict[str, str] = Field(..., description="Session variables to update") +``` + +### 7.2 Response Models + +```python +from pydantic import BaseModel +from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from datetime import datetime + +class HealthResponse(BaseModel): + status: Literal["healthy"] + version: str + active_sessions: int + +class CreateSessionResponse(BaseModel): + session_id: str + status: Literal["created"] + cwd: str + +class StopSessionResponse(BaseModel): + session_id: str + status: Literal["stopped"] + +class SessionInfoResponse(BaseModel): + session_id: str + status: Literal["running", "stopped"] + created_at: datetime + last_activity: datetime + loaded_plugins: List[str] + execution_count: int + cwd: str + +class LoadPluginResponse(BaseModel): + name: str + status: Literal["loaded"] + +class ArtifactModel(BaseModel): + name: str + type: str # "image", "file", "chart", "svg", etc. + mime_type: str + original_name: str + file_name: str + file_content: Optional[str] = None # Base64 for small files + file_content_encoding: Optional[str] = None + preview: str + download_url: Optional[str] = None # For large files + +class ExecuteCodeResponse(BaseModel): + execution_id: str + is_success: bool + error: Optional[str] + output: Any + stdout: List[str] + stderr: List[str] + log: List[Tuple[str, str, str]] + artifact: List[ArtifactModel] + variables: List[Tuple[str, str]] + +class ExecuteStreamResponse(BaseModel): + execution_id: str + stream_url: str + +class UpdateVariablesResponse(BaseModel): + status: Literal["updated"] + variables: Dict[str, str] + +class ErrorResponse(BaseModel): + detail: str +``` + +### 7.3 SSE Event Models + +```python +class OutputEvent(BaseModel): + type: Literal["stdout", "stderr"] + text: str + +class ResultEvent(BaseModel): + execution_id: str + is_success: bool + error: Optional[str] + output: Any + stdout: List[str] + stderr: List[str] + log: List[Tuple[str, str, str]] + artifact: List[ArtifactModel] + variables: List[Tuple[str, str]] +``` + +--- + +## 8. Sequence Diagrams + +### 8.1 Auto-Start Local Server Flow + +``` +┌────────────┐ ┌──────────────────────┐ ┌─────────────────┐ ┌────────────┐ +│ TaskWeaver │ │ExecutionServiceProv. │ │ ServerLauncher │ │ Server │ +│ Main │ │ │ │ │ │ Process │ +└─────┬──────┘ └──────────┬───────────┘ └────────┬────────┘ └──────┬─────┘ + │ │ │ │ + │ initialize() │ │ │ + │──────────────────────▶│ │ │ + │ │ │ │ + │ │ is_server_running()? │ │ + │ │─────────────────────────▶│ │ + │ │ │ │ + │ │ No │ │ + │ │◀─────────────────────────│ │ + │ │ │ │ + │ │ start() │ │ + │ │─────────────────────────▶│ │ + │ │ │ │ + │ │ │ subprocess.Popen │ + │ │ │────────────────────▶│ + │ │ │ │ + │ │ │ (server starts) │ + │ │ │ │ + │ │ │ wait for ready │ + │ │ │─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶│ + │ │ │ │ + │ │ │ health check OK │ + │ │ │◀─ ─ ─ ─ ─ ─ ─ ─ ─ ─│ + │ │ │ │ + │ │ ready │ │ + │ │◀─────────────────────────│ │ + │ │ │ │ + │ ready │ │ │ + │◀──────────────────────│ │ │ + │ │ │ │ +``` + +### 8.2 Code Execution Flow + +``` +┌────────────┐ ┌────────────────┐ ┌─────────────────┐ ┌────────────┐ +│ CodeExec. │ │ExecutionClient │ │ Server │ │Environment │ +└─────┬──────┘ └───────┬────────┘ └────────┬────────┘ └──────┬─────┘ + │ │ │ │ + │ execute_code() │ │ │ + │───────────────────▶│ │ │ + │ │ │ │ + │ │ POST /execute │ │ + │ │──────────────────────▶│ │ + │ │ │ │ + │ │ │ execute_code() │ + │ │ │────────────────────▶│ + │ │ │ │ + │ │ │ (Jupyter kernel │ + │ │ │ executes code) │ + │ │ │ │ + │ │ │ ExecutionResult │ + │ │ │◀────────────────────│ + │ │ │ │ + │ │ 200 {result} │ │ + │ │◀──────────────────────│ │ + │ │ │ │ + │ ExecutionResult │ │ │ + │◀───────────────────│ │ │ + │ │ │ │ +``` + +### 8.3 Plugin Loading Flow + +``` +┌────────────┐ ┌────────────────┐ ┌─────────────────┐ ┌────────────┐ +│ CodeExec. │ │ExecutionClient │ │ Server │ │Environment │ +└─────┬──────┘ └───────┬────────┘ └────────┬────────┘ └──────┬─────┘ + │ │ │ │ + │ Plugin source │ │ │ + │ read from disk │ │ │ + │ │ │ │ + │ load_plugin( │ │ │ + │ name, code, │ │ │ + │ config) │ │ │ + │───────────────────▶│ │ │ + │ │ │ │ + │ │ POST /plugins │ │ + │ │ {name, code, config} │ │ + │ │──────────────────────▶│ │ + │ │ │ │ + │ │ │ load_plugin() │ + │ │ │────────────────────▶│ + │ │ │ │ + │ │ │ (magic commands │ + │ │ │ register plugin) │ + │ │ │ │ + │ │ │ OK │ + │ │ │◀────────────────────│ + │ │ │ │ + │ │ 200 {loaded} │ │ + │ │◀──────────────────────│ │ + │ │ │ │ + │ OK │ │ │ + │◀───────────────────│ │ │ +``` + +--- + +## 9. Multi-Session Architecture + +### 9.1 Session Isolation Model + +The execution server supports multiple concurrent sessions, each with complete isolation: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ EXECUTION SERVER (FastAPI) │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ ServerSessionManager │ │ +│ │ │ │ +│ │ _sessions: Dict[str, ServerSession] │ │ +│ │ _lock: threading.RLock() ← Thread-safe access │ │ +│ │ │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ +│ │ │ ServerSession │ │ ServerSession │ │ ServerSession │ │ │ +│ │ │ "session-001" │ │ "session-002" │ │ "session-003" │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ │ +│ │ │ │ Environment │ │ │ │ Environment │ │ │ │ Environment │ │ │ │ +│ │ │ └──────┬──────┘ │ │ └──────┬──────┘ │ │ └──────┬──────┘ │ │ │ +│ │ └────────┼────────┘ └────────┼────────┘ └────────┼────────┘ │ │ +│ └────────────┼────────────────────┼────────────────────┼───────────────┘ │ +│ │ │ │ │ +└───────────────┼────────────────────┼────────────────────┼──────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐ +│ Jupyter Kernel 1 │ │ Jupyter Kernel 2 │ │ Jupyter Kernel 3 │ +│ (isolated process) │ │ (isolated process) │ │ (isolated process) │ +│ │ │ │ │ │ +│ session_var: {...} │ │ session_var: {...} │ │ session_var: {...} │ +│ plugins: [...] │ │ plugins: [...] │ │ plugins: [...] │ +│ cwd: /sessions/001 │ │ cwd: /sessions/002 │ │ cwd: /sessions/003 │ +└───────────────────────┘ └───────────────────────┘ └───────────────────────┘ +``` + +### 9.2 Key Components + +#### ServerSessionManager + +Central coordinator for all sessions: + +```python +class ServerSessionManager: + _sessions: Dict[str, ServerSession] = {} # Session storage + _lock: threading.RLock() # Thread-safe access + work_dir: str # Base directory for session data +``` + +| Responsibility | Implementation | +|----------------|----------------| +| Session storage | `Dict[str, ServerSession]` keyed by session_id | +| Thread safety | `threading.RLock()` for all session access | +| Session isolation | Each session has its own `Environment` instance | +| Async execution | `run_in_executor()` to avoid blocking FastAPI event loop | + +#### ServerSession + +Per-session state container: + +```python +@dataclass +class ServerSession: + session_id: str + environment: Environment # Own kernel instance + created_at: datetime + last_activity: datetime + loaded_plugins: List[str] + execution_count: int + cwd: str # Isolated working directory + session_dir: str # Session data directory +``` + +#### Environment + +Wraps Jupyter kernel management: + +```python +class Environment: + session_dict: Dict[str, EnvSession] = {} # Kernel sessions + client_dict: Dict[str, BlockingKernelClient] # Kernel clients + multi_kernel_manager: MultiKernelManager # Jupyter kernel manager +``` + +### 9.3 Session Isolation + +| Aspect | How Isolated | +|--------|--------------| +| **Jupyter Kernel** | Each session runs in a separate OS process | +| **Working Directory** | `{work_dir}/sessions/{session_id}/cwd/` | +| **Session Variables** | Stored per-session in kernel memory | +| **Plugins** | Loaded independently per-session | +| **Artifacts** | Saved to session-specific `cwd` directory | +| **Memory** | Kernel process has its own memory space | + +### 9.4 Directory Structure + +``` +work_dir/ +└── sessions/ + ├── session-001/ + │ ├── ces/ + │ │ ├── conn-session-001-knl-xxx.json # Kernel connection file + │ │ └── kernel_logging.log # Kernel logs + │ └── cwd/ + │ ├── output.png # Artifacts saved here + │ └── data.csv + ├── session-002/ + │ ├── ces/ + │ └── cwd/ + └── session-003/ + ├── ces/ + └── cwd/ +``` + +### 9.5 Thread Safety + +All session access is protected by a reentrant lock: + +```python +def create_session(self, session_id: str, ...) -> ServerSession: + with self._lock: # Lock acquired + if session_id in self._sessions: + raise ValueError(...) + # ... create session ... + self._sessions[session_id] = session # Safe write + return session # Lock released + +def stop_session(self, session_id: str) -> None: + with self._lock: # Lock acquired + session = self._sessions[session_id] + session.environment.stop_session(...) + del self._sessions[session_id] # Safe delete +``` + +### 9.6 Async Execution + +Code execution is CPU-bound (runs in Jupyter kernel), so it's offloaded to a thread pool to avoid blocking the FastAPI async event loop: + +```python +async def execute_code_async(self, session_id, exec_id, code, on_output): + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, # Default thread pool + lambda: self.execute_code(session_id, exec_id, code, on_output), + ) +``` + +### 9.7 Artifact Handling + +Artifacts (images, charts, files) are automatically: + +1. **Captured** from Jupyter kernel display outputs +2. **Saved** to the session's `cwd` directory +3. **Served** via HTTP at `/api/v1/sessions/{session_id}/artifacts/{filename}` + +```python +def _save_inline_artifacts(self, session: ServerSession, result: ExecutionResult): + for artifact in result.artifact: + if artifact.file_content and not artifact.file_name: + # Decode base64 content and save to disk + file_path = os.path.join(session.cwd, f"{artifact.name}_image.png") + with open(file_path, "wb") as f: + f.write(base64.b64decode(artifact.file_content)) + artifact.file_name = file_name # Update for download URL +``` + +--- + +## 10. Error Handling + +### 9.1 Error Categories + +| Category | HTTP Status | Client Exception | Description | +|----------|-------------|------------------|-------------| +| Authentication | 401 | `ExecutionClientError` | Invalid/missing API key | +| Not Found | 404 | `ExecutionClientError` | Session/artifact not found | +| Conflict | 409 | `ExecutionClientError` | Session already exists | +| Bad Request | 400 | `ExecutionClientError` | Plugin load failed, invalid request | +| Execution Error | 200 | None (in result) | Code execution failed (normal flow) | +| Server Error | 500 | `ExecutionClientError` | Unexpected server failure | +| Connection Error | N/A | `ExecutionClientError` | Cannot connect to server | +| Timeout | N/A | `httpx.TimeoutException` | Request/execution timeout | + +--- + +## 10. Implementation Order + +1. **Phase 1: Server Core** + - `taskweaver/ces/server/models.py` + - `taskweaver/ces/server/session_manager.py` + - `taskweaver/ces/server/routes.py` + - `taskweaver/ces/server/app.py` + - `taskweaver/ces/server/__main__.py` + +2. **Phase 2: Client** + - `taskweaver/ces/client/execution_client.py` + - `taskweaver/ces/client/server_launcher.py` + +3. **Phase 3: Integration** + - `taskweaver/ces/manager/execution_service.py` + - Configuration updates + - DI module updates + +4. **Phase 4: Testing** + - Unit tests + - Integration tests + +5. **Phase 5: Documentation & Deployment** + - Update AGENTS.md + - Dockerfile + - User documentation + +--- + +## 11. Deployment + +### 12.1 Standalone Server + +```bash +# Install dependencies +pip install fastapi uvicorn httpx + +# Start server +python -m taskweaver.ces.server \ + --host 0.0.0.0 \ + --port 8000 \ + --api-key "your-secret-key" \ + --work-dir /var/taskweaver/sessions +``` + +### 12.2 Docker Image + +```dockerfile +# Dockerfile.executor +FROM python:3.10-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy TaskWeaver package +COPY taskweaver/ ./taskweaver/ + +# Create workspace directory +RUN mkdir -p /app/workspace + +# Environment variables +ENV TASKWEAVER_SERVER_HOST=0.0.0.0 +ENV TASKWEAVER_SERVER_PORT=8000 +ENV TASKWEAVER_SERVER_WORK_DIR=/app/workspace + +EXPOSE 8000 + +# Run server +CMD ["python", "-m", "taskweaver.ces.server"] +``` + +### 12.3 Docker Compose + +```yaml +# docker-compose.yml +version: '3.8' + +services: + executor: + build: + context: . + dockerfile: Dockerfile.executor + ports: + - "8000:8000" + volumes: + - ./workspace:/app/workspace + environment: + - TASKWEAVER_SERVER_API_KEY=${API_KEY:-} + - TASKWEAVER_SERVER_SESSION_TIMEOUT=3600 + restart: unless-stopped +``` diff --git a/project/taskweaver_config.json b/project/taskweaver_config.json index 0f81d9e01..c8cee06d9 100644 --- a/project/taskweaver_config.json +++ b/project/taskweaver_config.json @@ -14,6 +14,5 @@ "code_interpreter", "recepta" ], - "session.max_internal_chat_round_num": 100, - "execution_service.kernel_mode": "local" + "session.max_internal_chat_round_num": 100 } \ No newline at end of file diff --git a/taskweaver/app/app.py b/taskweaver/app/app.py index ef23c6965..7c5232890 100644 --- a/taskweaver/app/app.py +++ b/taskweaver/app/app.py @@ -12,18 +12,87 @@ from taskweaver.session.session import Session +def _cleanup_existing_servers(port: int = 8000) -> Optional[int]: + """Check for and kill any existing server processes on the specified port. + + This is called at TaskWeaver startup to ensure a clean state. + + Args: + port: The port to check for existing servers. + + Returns: + The PID of the killed server, or None if no server was found. + """ + import os + import platform + import signal + import subprocess + import time + + def get_pid_on_port(port: int) -> Optional[int]: + """Get the PID of the process listening on the port.""" + try: + if platform.system() == "Windows": + result = subprocess.run( + ["netstat", "-ano"], + capture_output=True, + text=True, + timeout=10, + ) + for line in result.stdout.split("\n"): + if f":{port}" in line and "LISTENING" in line: + parts = line.split() + if parts: + return int(parts[-1]) + else: + result = subprocess.run( + ["lsof", "-ti", f":{port}"], + capture_output=True, + text=True, + timeout=10, + ) + if result.stdout.strip(): + return int(result.stdout.strip().split("\n")[0]) + except Exception: + pass + return None + + pid = get_pid_on_port(port) + if pid is None: + return None + + try: + if platform.system() == "Windows": + subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=10) + else: + os.kill(pid, signal.SIGTERM) + time.sleep(1) + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + + # Wait for port to be released + for _ in range(10): + if get_pid_on_port(port) is None: + return pid + time.sleep(0.5) + + return pid + except Exception: + return None + + class TaskWeaverApp(object): def __init__( self, app_dir: Optional[str] = None, - use_local_uri: Optional[bool] = None, config: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> None: """ Initialize the TaskWeaver app. :param app_dir: The project directory. - :param use_local_uri: Whether to use local URI for artifacts. :param config: The configuration. :param kwargs: The additional arguments. """ @@ -34,8 +103,6 @@ def __init__( **(config or {}), **(kwargs or {}), } - if use_local_uri is not None: - config["use_local_uri"] = use_local_uri config_src = AppConfigSource( config_file_path=app_config_file, diff --git a/taskweaver/ces/AGENTS.md b/taskweaver/ces/AGENTS.md index dc06fb748..889a34813 100644 --- a/taskweaver/ces/AGENTS.md +++ b/taskweaver/ces/AGENTS.md @@ -1,57 +1,240 @@ # Code Execution Service (CES) - AGENTS.md -Jupyter kernel-based code execution with local and container modes. +Jupyter kernel-based code execution with server architecture supporting local, container, and remote deployment modes. ## Structure ``` ces/ -├── environment.py # Environment class - kernel management (~700 lines) -├── common.py # ExecutionResult, ExecutionArtifact, EnvPlugin dataclasses -├── client.py # CES client for remote execution -├── __init__.py # Exports -├── kernel/ # Custom Jupyter kernel implementation -│ └── ext.py # IPython magic commands for TaskWeaver -├── runtime/ # Runtime support files -└── manager/ # Session/kernel lifecycle management +├── __init__.py # Factory: code_execution_service_factory() +├── common.py # Client/Manager ABCs, ExecutionResult, ExecutionArtifact +├── environment.py # Environment class - kernel management (~700 lines) +│ +├── server/ # HTTP server package (FastAPI) +│ ├── __init__.py # Exports +│ ├── models.py # Pydantic request/response models +│ ├── session_manager.py # ServerSessionManager - wraps Environment +│ ├── routes.py # API route handlers with SSE streaming +│ ├── app.py # FastAPI application factory +│ └── __main__.py # CLI: python -m taskweaver.ces.server +│ +├── client/ # HTTP client package +│ ├── __init__.py # Exports +│ ├── execution_client.py # ExecutionClient - implements Client ABC +│ └── server_launcher.py # Auto-start server subprocess/container +│ +├── manager/ # Manager implementations +│ ├── __init__.py # Exports +│ ├── sub_proc.py # SubProcessManager (used internally by server) +│ └── execution_service.py # ExecutionServiceProvider +│ +├── kernel/ # Custom Jupyter kernel implementation +│ └── ext.py # IPython magic commands for TaskWeaver +│ +└── runtime/ # Runtime support files +``` + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ TASKWEAVER CLIENT │ +│ │ +│ ┌─────────────────┐ ┌──────────────────┐ ┌───────────────────────┐ │ +│ │ CodeInterpreter │───▶│ CodeExecutor │───▶│ExecutionServiceProv. │ │ +│ └─────────────────┘ └──────────────────┘ └───────────┬───────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────┐ │ +│ │ ExecutionClient │ │ +│ │ (HTTP) │ │ +│ └─────────┬─────────┘ │ +└──────────────────────────────────────────────────────────┼──────────────────┘ + │ + │ HTTP (localhost:8000 or remote) + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ EXECUTION SERVER │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ FastAPI Application │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌─────────────────┐ │ │ +│ │ │ /sessions │ │ /plugins │ │ /execute │ │ /artifacts │ │ │ +│ │ └───────────┘ └───────────┘ └───────────┘ └─────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ ServerSessionManager │ │ +│ │ sessions: Dict[session_id, ServerSession] │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ Environment (EnvMode.Local) │ │ +│ │ - Jupyter kernel management via MultiKernelManager │ │ +│ │ - Plugin loading via magic commands │ │ +│ │ - Code execution and output capture │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` ## Key Classes -### Environment (environment.py) -Main orchestrator for code execution: -- `EnvMode.Local`: Direct kernel via `MultiKernelManager` -- `EnvMode.Container`: Docker container with mounted volumes +### ABCs (common.py) + +```python +class Client(ABC): + """Interface for execution clients.""" + def start(self) -> None: ... + def stop(self) -> None: ... + def load_plugin(self, name: str, code: str, config: Dict) -> None: ... + def execute_code(self, exec_id: str, code: str, on_output: Callable = None) -> ExecutionResult: ... + +class Manager(ABC): + """Interface for execution managers.""" + def initialize(self) -> None: ... + def clean_up(self) -> None: ... + def get_session_client(self, session_id: str, ...) -> Client: ... + def get_kernel_mode(self) -> KernelModeType: ... +``` ### ExecutionResult (common.py) + ```python @dataclass class ExecutionResult: execution_id: str code: str - is_success: bool - error: str - output: str - stdout: List[str] - stderr: List[str] - log: List[str] - artifact: List[ExecutionArtifact] - variables: Dict[str, str] # Session variables from execution + is_success: bool = False + error: Optional[str] = None + output: Union[str, List[Tuple[str, str]]] = "" + stdout: List[str] = field(default_factory=list) + stderr: List[str] = field(default_factory=list) + log: List[Tuple[str, str, str]] = field(default_factory=list) + artifact: List[ExecutionArtifact] = field(default_factory=list) + variables: List[Tuple[str, str]] = field(default_factory=list) ``` -## Execution Flow +### Server-Side Classes -1. `start_session()` - Creates kernel (local or container) -2. `load_plugin()` - Registers plugins in kernel namespace -3. `execute_code()` - Runs code, captures output/artifacts -4. `stop_session()` - Cleanup kernel/container +| Class | File | Purpose | +|-------|------|---------| +| `ServerSessionManager` | `server/session_manager.py` | Manages multiple sessions, wraps Environment | +| `ServerSession` | `server/session_manager.py` | Per-session state (environment, plugins, stats) | +| Pydantic Models | `server/models.py` | Request/response models for HTTP API | -## Container Mode Specifics +### Client-Side Classes -- Image: `taskweavercontainers/taskweaver-executor:latest` -- Ports: 5 ports mapped (shell, iopub, stdin, hb, control) -- Volumes: `ces/` and `cwd/` mounted read-write -- Connection file written to `ces/conn-{session}-{kernel}.json` +| Class | File | Purpose | +|-------|------|---------| +| `ExecutionClient` | `client/execution_client.py` | HTTP client implementing Client ABC | +| `ServerLauncher` | `client/server_launcher.py` | Auto-start server as subprocess/container | +| `ExecutionServiceProvider` | `manager/execution_service.py` | Manager implementation using HTTP client | +| `ExecutionServiceClient` | `manager/execution_service.py` | Client wrapper for Provider | + +## Deployment Modes + +### 1. Local Process (Default) +``` +TaskWeaver ──HTTP──▶ Server (subprocess) ──▶ Jupyter Kernel + localhost:8000 +``` + +Server auto-starts when needed. Full filesystem access. + +### 2. Local Container +``` +TaskWeaver ──HTTP──▶ Docker Container ──▶ Jupyter Kernel + localhost:8000 +``` + +Isolated filesystem. Volumes mapped for workspace. + +### 3. Remote Server +``` +TaskWeaver ──HTTP──▶ Remote Machine ──▶ Jupyter Kernel + remote:8000 +``` + +Connect to pre-started server. API key required. + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/health` | Health check | +| POST | `/api/v1/sessions` | Create session | +| DELETE | `/api/v1/sessions/{id}` | Stop session | +| GET | `/api/v1/sessions/{id}` | Get session info | +| POST | `/api/v1/sessions/{id}/plugins` | Load plugin | +| POST | `/api/v1/sessions/{id}/execute` | Execute code | +| GET | `/api/v1/sessions/{id}/stream/{exec_id}` | SSE stream | +| POST | `/api/v1/sessions/{id}/variables` | Update variables | +| GET | `/api/v1/sessions/{id}/artifacts/{file}` | Download artifact | + +## Usage + +### Starting the Server Manually + +```bash +# Basic +python -m taskweaver.ces.server + +# With options +python -m taskweaver.ces.server \ + --host 0.0.0.0 \ + --port 8000 \ + --work-dir /var/taskweaver \ + --api-key "secret" +``` + +### Using the Factory + +```python +from taskweaver.ces import code_execution_service_factory + +# Default: local server with auto-start +manager = code_execution_service_factory(env_dir="/tmp/work") + +# Containerized server +manager = code_execution_service_factory( + env_dir="/tmp/work", + server_container=True, +) + +# Remote server +manager = code_execution_service_factory( + env_dir="/tmp/work", + server_url="http://remote:8000", + server_api_key="secret", + server_auto_start=False, +) +``` + +### Using the Client Directly + +```python +from taskweaver.ces.client import ExecutionClient + +with ExecutionClient( + session_id="my-session", + server_url="http://localhost:8000", +) as client: + client.start() + client.load_plugin("my_plugin", plugin_code, {"key": "value"}) + result = client.execute_code("exec-1", "print('Hello')") + print(result.stdout) # ['Hello\n'] + client.stop() +``` + +## Execution Flow + +1. **Session Creation**: `POST /sessions` → ServerSessionManager creates Environment +2. **Plugin Loading**: `POST /sessions/{id}/plugins` → Environment.load_plugin() +3. **Code Execution**: `POST /sessions/{id}/execute` → Environment.execute_code() +4. **Streaming**: SSE events for stdout/stderr during execution +5. **Session Cleanup**: `DELETE /sessions/{id}` → Environment.stop_session() ## Custom Kernel Magics (kernel/ext.py) @@ -64,10 +247,54 @@ class ExecutionResult: %%_taskweaver_update_session_var ``` -## Adding Plugin Support +## Error Handling + +| HTTP Status | Meaning | +|-------------|---------| +| 200 | Success (execution errors in response body) | +| 201 | Session created | +| 400 | Bad request (plugin load failed, invalid request) | +| 401 | Unauthorized (invalid API key) | +| 404 | Session/artifact not found | +| 409 | Session already exists | +| 500 | Internal server error | + +## Testing + +Unit tests in `tests/unit_tests/ces/`: + +| File | Coverage | +|------|----------| +| `test_server_models.py` | Pydantic models, utility functions | +| `test_session_manager.py` | ServerSessionManager (mocked Environment) | +| `test_execution_client.py` | ExecutionClient (mocked HTTP) | +| `test_server_launcher.py` | ServerLauncher (mocked subprocess/docker) | +| `test_execution_service.py` | ExecutionServiceProvider | + +Run tests: +```bash +pytest tests/unit_tests/ces/ -v +``` + +## Configuration -Plugins are loaded via magic commands: -1. `_taskweaver_plugin_register` - Registers plugin class -2. `_taskweaver_plugin_load` - Instantiates with config +Configuration options (in `taskweaver_config.json`): -Session variables updated via `%%_taskweaver_update_session_var` magic. +```json +{ + "execution.server.url": "http://localhost:8000", + "execution.server.api_key": "", + "execution.server.auto_start": true, + "execution.server.container": false, + "execution.server.container_image": "taskweavercontainers/taskweaver-executor:latest", + "execution.server.timeout": 300 +} +``` + +## Container Mode Details + +When `server_container=true`: +- Image: `taskweavercontainers/taskweaver-executor:latest` +- Port mapping: `8000/tcp` → host port +- Volume: `{env_dir}` → `/app/workspace` +- Server runs inside container with local kernel diff --git a/taskweaver/ces/__init__.py b/taskweaver/ces/__init__.py index 7d8a45eb4..e1e45bd7c 100644 --- a/taskweaver/ces/__init__.py +++ b/taskweaver/ces/__init__.py @@ -1,23 +1,69 @@ -from typing import Literal, Optional +"""Code Execution Service package. + +This module provides factory functions for creating execution service managers. +All execution goes through an HTTP server (local auto-start or remote). +""" + +from typing import Optional from taskweaver.ces.common import Manager from taskweaver.ces.manager.defer import DeferredManager -from taskweaver.ces.manager.sub_proc import SubProcessManager +from taskweaver.ces.manager.execution_service import ExecutionServiceProvider def code_execution_service_factory( env_dir: str, - kernel_mode: Literal["local", "container"] = "local", - custom_image: Optional[str] = None, + server_url: str = "http://localhost:8000", + server_api_key: Optional[str] = None, + server_auto_start: bool = True, + server_container: bool = False, + server_container_image: Optional[str] = None, + server_timeout: float = 300.0, + server_startup_timeout: float = 60.0, + server_kill_existing: bool = True, ) -> Manager: - def sub_proc_manager_factory() -> SubProcessManager: - return SubProcessManager( - env_dir=env_dir, - kernel_mode=kernel_mode, - custom_image=custom_image, + """Factory function to create the execution service manager. + + All execution uses the HTTP server architecture. By default, a local server + is auto-started. For remote execution, set server_url and auto_start=False. + + Args: + env_dir: Environment/working directory for session data. + server_url: URL of the execution server. + server_api_key: API key for server authentication. + server_auto_start: Whether to auto-start the server if not running. + server_container: Whether to run the server in a Docker container. + server_container_image: Docker image for the server container. + server_timeout: Request timeout for server communication. + server_startup_timeout: Maximum time to wait for server startup. + server_kill_existing: Whether to kill existing server on the port before starting. + + Returns: + Manager instance configured for server-based execution. + """ + + def server_manager_factory() -> ExecutionServiceProvider: + return ExecutionServiceProvider( + server_url=server_url, + api_key=server_api_key, + auto_start=server_auto_start, + container=server_container, + container_image=server_container_image, + work_dir=env_dir, + timeout=server_timeout, + startup_timeout=server_startup_timeout, + kill_existing=server_kill_existing, ) return DeferredManager( - kernel_mode=kernel_mode, - manager_factory=sub_proc_manager_factory, + kernel_mode="local", + manager_factory=server_manager_factory, ) + + +__all__ = [ + "Manager", + "DeferredManager", + "ExecutionServiceProvider", + "code_execution_service_factory", +] diff --git a/taskweaver/ces/client/__init__.py b/taskweaver/ces/client/__init__.py new file mode 100644 index 000000000..b44726c77 --- /dev/null +++ b/taskweaver/ces/client/__init__.py @@ -0,0 +1,13 @@ +"""TaskWeaver Execution Client package. + +This package provides an HTTP client for connecting to the TaskWeaver +Execution Server, as well as utilities for auto-starting the server. +""" + +from taskweaver.ces.client.execution_client import ExecutionClient +from taskweaver.ces.client.server_launcher import ServerLauncher + +__all__ = [ + "ExecutionClient", + "ServerLauncher", +] diff --git a/taskweaver/ces/client/execution_client.py b/taskweaver/ces/client/execution_client.py new file mode 100644 index 000000000..0fcdb2774 --- /dev/null +++ b/taskweaver/ces/client/execution_client.py @@ -0,0 +1,444 @@ +"""HTTP client for the TaskWeaver Execution Server. + +This module implements the Client ABC using HTTP requests to communicate +with the remote execution server. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Callable, Dict, List, Optional, Tuple + +import httpx + +from taskweaver.ces.common import Client, ExecutionArtifact, ExecutionResult + +logger = logging.getLogger(__name__) + + +class ExecutionClientError(Exception): + """Exception raised when the execution client encounters an error.""" + + def __init__(self, message: str, status_code: Optional[int] = None): + super().__init__(message) + self.status_code = status_code + + +class ExecutionClient(Client): + """HTTP client for the TaskWeaver Execution Server. + + This client implements the Client ABC and communicates with the + execution server via HTTP API calls. + """ + + def __init__( + self, + session_id: str, + server_url: str = "http://localhost:8000", + api_key: Optional[str] = None, + timeout: float = 300.0, + cwd: Optional[str] = None, + ) -> None: + """Initialize the execution client. + + Args: + session_id: Unique session identifier. + server_url: URL of the execution server. + api_key: Optional API key for authentication. + timeout: Request timeout in seconds. + cwd: Optional working directory for code execution. + """ + self.session_id = session_id + self.server_url = server_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.cwd = cwd + self._started = False + + # Build headers + self._headers: Dict[str, str] = { + "Content-Type": "application/json", + } + if api_key: + self._headers["X-API-Key"] = api_key + + # HTTP client with connection pooling + self._client = httpx.Client( + base_url=self.server_url, + headers=self._headers, + timeout=httpx.Timeout(timeout, connect=30.0), + ) + + @property + def api_base(self) -> str: + """Get the API base URL.""" + return f"{self.server_url}/api/v1" + + def _handle_response(self, response: httpx.Response) -> Dict[str, Any]: + """Handle HTTP response and raise appropriate errors. + + Args: + response: HTTP response object. + + Returns: + Parsed JSON response. + + Raises: + ExecutionClientError: If the request failed. + """ + if response.status_code >= 400: + try: + error_data = response.json() + detail = error_data.get("detail", response.text) + except Exception: + detail = response.text + + raise ExecutionClientError( + f"Server error ({response.status_code}): {detail}", + status_code=response.status_code, + ) + + return response.json() + + def health_check(self) -> Dict[str, Any]: + """Check if the server is healthy. + + Returns: + Health check response with status, version, and active sessions. + + Raises: + ExecutionClientError: If the server is not healthy. + """ + try: + response = self._client.get("/api/v1/health") + return self._handle_response(response) + except httpx.ConnectError as e: + raise ExecutionClientError(f"Cannot connect to server: {e}") + except httpx.TimeoutException as e: + raise ExecutionClientError(f"Connection timeout: {e}") + + def start(self) -> None: + """Start the execution session by creating it on the server. + + Raises: + ExecutionClientError: If session creation fails. + """ + if self._started: + return + + try: + response = self._client.post( + "/api/v1/sessions", + json={ + "session_id": self.session_id, + "cwd": self.cwd, + }, + ) + result = self._handle_response(response) + self.cwd = result.get("cwd", self.cwd) + self._started = True + logger.info(f"Started session {self.session_id} on {self.server_url}") + except ExecutionClientError as e: + if e.status_code == 409: + # Session already exists, consider it started + self._started = True + logger.info(f"Session {self.session_id} already exists, reusing") + else: + raise + + def stop(self) -> None: + """Stop the execution session. + + Raises: + ExecutionClientError: If session stop fails. + """ + if not self._started: + return + + try: + response = self._client.delete(f"/api/v1/sessions/{self.session_id}") + self._handle_response(response) + self._started = False + logger.info(f"Stopped session {self.session_id}") + except ExecutionClientError as e: + if e.status_code == 404: + self._started = False + else: + raise + except (httpx.ConnectError, httpx.TimeoutException): + logger.debug(f"Server unavailable while stopping session {self.session_id} (expected during shutdown)") + self._started = False + + def get_session_info(self) -> Dict[str, Any]: + """Get information about the current session. + + Returns: + Session information including status, plugins, execution count. + + Raises: + ExecutionClientError: If the request fails. + """ + response = self._client.get(f"/api/v1/sessions/{self.session_id}") + return self._handle_response(response) + + def load_plugin( + self, + plugin_name: str, + plugin_code: str, + plugin_config: Dict[str, str], + ) -> None: + """Load a plugin into the session. + + Args: + plugin_name: Name of the plugin. + plugin_code: Plugin source code. + plugin_config: Plugin configuration dictionary. + + Raises: + ExecutionClientError: If plugin loading fails. + """ + response = self._client.post( + f"/api/v1/sessions/{self.session_id}/plugins", + json={ + "name": plugin_name, + "code": plugin_code, + "config": plugin_config, + }, + ) + self._handle_response(response) + logger.info(f"Loaded plugin {plugin_name} in session {self.session_id}") + + def test_plugin(self, plugin_name: str) -> None: + """Test a loaded plugin. + + Note: This is currently a no-op as the server doesn't have a test endpoint. + Plugin testing happens during load. + + Args: + plugin_name: Name of the plugin to test. + """ + # Plugin testing is implicit during load + # Could add a dedicated test endpoint in the future + logger.debug(f"Plugin test for {plugin_name} (implicit during load)") + + def update_session_var(self, session_var_dict: Dict[str, str]) -> None: + """Update session variables. + + Args: + session_var_dict: Dictionary of session variables to update. + + Raises: + ExecutionClientError: If the update fails. + """ + response = self._client.post( + f"/api/v1/sessions/{self.session_id}/variables", + json={"variables": session_var_dict}, + ) + self._handle_response(response) + + def execute_code( + self, + exec_id: str, + code: str, + on_output: Optional[Callable[[str, str], None]] = None, + ) -> ExecutionResult: + """Execute code in the session. + + Args: + exec_id: Unique execution identifier. + code: Python code to execute. + on_output: Optional callback for streaming output. + Signature: on_output(stream_name: str, text: str) + + Returns: + ExecutionResult with the execution outcome. + + Raises: + ExecutionClientError: If execution fails. + """ + if on_output is not None: + # Use streaming execution + return self._execute_code_streaming(exec_id, code, on_output) + else: + # Use synchronous execution + return self._execute_code_sync(exec_id, code) + + def _execute_code_sync(self, exec_id: str, code: str) -> ExecutionResult: + """Execute code synchronously. + + Args: + exec_id: Unique execution identifier. + code: Python code to execute. + + Returns: + ExecutionResult with the execution outcome. + """ + response = self._client.post( + f"/api/v1/sessions/{self.session_id}/execute", + json={ + "exec_id": exec_id, + "code": code, + "stream": False, + }, + ) + result = self._handle_response(response) + return self._parse_execution_result(result, code) + + def _execute_code_streaming( + self, + exec_id: str, + code: str, + on_output: Callable[[str, str], None], + ) -> ExecutionResult: + """Execute code with streaming output. + + Args: + exec_id: Unique execution identifier. + code: Python code to execute. + on_output: Callback for streaming output. + + Returns: + ExecutionResult with the execution outcome. + """ + # First, initiate streaming execution + response = self._client.post( + f"/api/v1/sessions/{self.session_id}/execute", + json={ + "exec_id": exec_id, + "code": code, + "stream": True, + }, + ) + init_result = self._handle_response(response) + stream_url = init_result.get("stream_url", "") + + # Extract path from stream URL + if stream_url.startswith("http"): + # Full URL provided + stream_path = stream_url.replace(self.server_url, "") + else: + stream_path = stream_url + + # Connect to SSE stream + final_result: Optional[Dict[str, Any]] = None + + with self._client.stream("GET", stream_path) as sse_response: + for line in sse_response.iter_lines(): + if not line: + continue + + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data_str = line[5:].strip() + if not data_str: + continue + + try: + data = json.loads(data_str) + except json.JSONDecodeError: + continue + + if event_type == "output": + # Stream output to callback + stream_type = data.get("type", "stdout") + text = data.get("text", "") + on_output(stream_type, text) + elif event_type == "result": + final_result = data + elif event_type == "done": + break + + if final_result is None: + raise ExecutionClientError("No result received from streaming execution") + + return self._parse_execution_result(final_result, code) + + def _parse_execution_result( + self, + result: Dict[str, Any], + code: str, + ) -> ExecutionResult: + """Parse the execution result from the server response. + + Args: + result: Server response dictionary. + code: The executed code. + + Returns: + ExecutionResult object. + """ + artifacts: List[ExecutionArtifact] = [] + for art_data in result.get("artifact", []): + artifact = ExecutionArtifact( + name=art_data.get("name", ""), + type=art_data.get("type", "file"), + mime_type=art_data.get("mime_type", ""), + original_name=art_data.get("original_name", ""), + file_name=art_data.get("file_name", ""), + file_content=art_data.get("file_content", ""), + file_content_encoding=art_data.get("file_content_encoding", "str"), + preview=art_data.get("preview", ""), + download_url=art_data.get("download_url", ""), + ) + artifacts.append(artifact) + + # Parse log entries + log: List[Tuple[str, str, str]] = [] + for log_entry in result.get("log", []): + if isinstance(log_entry, (list, tuple)) and len(log_entry) >= 3: + log.append((str(log_entry[0]), str(log_entry[1]), str(log_entry[2]))) + + # Parse variables + variables: List[Tuple[str, str]] = [] + for var_entry in result.get("variables", []): + if isinstance(var_entry, (list, tuple)) and len(var_entry) >= 2: + variables.append((str(var_entry[0]), str(var_entry[1]))) + + return ExecutionResult( + execution_id=result.get("execution_id", ""), + code=code, + is_success=result.get("is_success", False), + error=result.get("error"), + output=result.get("output", ""), + stdout=result.get("stdout", []), + stderr=result.get("stderr", []), + log=log, + artifact=artifacts, + variables=variables, + ) + + def download_artifact(self, filename: str) -> bytes: + """Download an artifact file from the server. + + Args: + filename: Name of the artifact file. + + Returns: + Raw file content as bytes. + + Raises: + ExecutionClientError: If download fails. + """ + response = self._client.get( + f"/api/v1/sessions/{self.session_id}/artifacts/{filename}", + ) + if response.status_code >= 400: + raise ExecutionClientError( + f"Failed to download artifact: {response.text}", + status_code=response.status_code, + ) + return response.content + + def close(self) -> None: + """Close the HTTP client and release resources.""" + self._client.close() + + def __enter__(self) -> "ExecutionClient": + """Context manager entry.""" + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Context manager exit.""" + self.close() diff --git a/taskweaver/ces/client/server_launcher.py b/taskweaver/ces/client/server_launcher.py new file mode 100644 index 000000000..56d3793ca --- /dev/null +++ b/taskweaver/ces/client/server_launcher.py @@ -0,0 +1,415 @@ +"""Server launcher for auto-starting the execution server. + +This module provides utilities to automatically start the execution server +as a subprocess or Docker container when needed. +""" + +from __future__ import annotations + +import logging +import os +import signal +import subprocess +import sys +import time +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + + +class ServerLauncherError(Exception): + """Exception raised when server launch fails.""" + + +class ServerLauncher: + """Manages the lifecycle of the execution server. + + This class can start the server as a subprocess or Docker container, + wait for it to become ready, and shut it down when no longer needed. + """ + + DEFAULT_IMAGE = "taskweavercontainers/taskweaver-executor:latest" + + def __init__( + self, + host: str = "localhost", + port: int = 8000, + api_key: Optional[str] = None, + work_dir: Optional[str] = None, + container: bool = False, + container_image: Optional[str] = None, + startup_timeout: float = 60.0, + kill_existing: bool = True, + ) -> None: + """Initialize the server launcher. + + Args: + host: Host to bind the server to. + port: Port to bind the server to. + api_key: Optional API key for authentication. + work_dir: Working directory for session data. + container: Whether to run the server in a Docker container. + container_image: Docker image to use (only if container=True). + startup_timeout: Maximum time to wait for server startup. + kill_existing: Whether to kill existing server on the port before starting. + """ + self.host = host + self.port = port + self.api_key = api_key + self.work_dir = work_dir or os.getcwd() + self.container = container + self.container_image = container_image or self.DEFAULT_IMAGE + self.startup_timeout = startup_timeout + self.kill_existing = kill_existing + + self._process: Optional[subprocess.Popen[bytes]] = None + self._container_id: Optional[str] = None + self._started = False + + @property + def server_url(self) -> str: + """Get the server URL.""" + return f"http://{self.host}:{self.port}" + + def is_server_running(self) -> bool: + """Check if the server is already running and healthy. + + Returns: + True if server is running and responding to health checks. + """ + try: + headers = {} + if self.api_key: + headers["X-API-Key"] = self.api_key + + response = httpx.get( + f"{self.server_url}/api/v1/health", + headers=headers, + timeout=5.0, + ) + return response.status_code == 200 + except Exception: + return False + + def _get_pid_on_port(self) -> Optional[int]: + """Get the PID of the process listening on the configured port. + + Returns: + PID if found, None otherwise. + """ + import platform + + try: + if platform.system() == "Windows": + # Use netstat on Windows + result = subprocess.run( + ["netstat", "-ano"], + capture_output=True, + text=True, + timeout=10, + ) + for line in result.stdout.split("\n"): + if f":{self.port}" in line and "LISTENING" in line: + parts = line.split() + if parts: + return int(parts[-1]) + else: + # Use lsof on Unix-like systems + result = subprocess.run( + ["lsof", "-ti", f":{self.port}"], + capture_output=True, + text=True, + timeout=10, + ) + if result.stdout.strip(): + return int(result.stdout.strip().split("\n")[0]) + except Exception as e: + logger.debug(f"Failed to get PID on port {self.port}: {e}") + return None + + def kill_existing_server(self) -> bool: + """Kill any existing server process on the configured port. + + Returns: + True if a server was killed, False if no server was found. + """ + pid = self._get_pid_on_port() + if pid is None: + return False + + logger.info(f"Killing existing server process (PID: {pid}) on port {self.port}") + try: + import platform + + if platform.system() == "Windows": + subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=10) + else: + os.kill(pid, signal.SIGTERM) + # Give it a moment to terminate gracefully + time.sleep(1) + # Force kill if still running + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass # Already terminated + + # Wait for port to be released + for _ in range(10): + if not self.is_server_running() and self._get_pid_on_port() is None: + logger.info(f"Successfully killed server on port {self.port}") + return True + time.sleep(0.5) + + logger.warning(f"Server process may still be running on port {self.port}") + return True + except Exception as e: + logger.error(f"Failed to kill server process: {e}") + return False + + def start(self) -> None: + """Start the execution server. + + If kill_existing is True and an existing server is found, it will be killed first. + Otherwise, if a server is already running, this is a no-op. + + Raises: + ServerLauncherError: If the server fails to start. + """ + if self._started: + return + + if self.is_server_running(): + if self.kill_existing: + logger.info(f"Found existing server at {self.server_url}, killing it...") + self.kill_existing_server() + # Wait a bit for port to be released + time.sleep(1) + else: + logger.info(f"Code Execution Server already running at {self.server_url}") + self._started = True + return + + if self.container: + self._start_container() + else: + self._start_subprocess() + + self._wait_for_ready() + self._started = True + + def _start_subprocess(self) -> None: + """Start the server as a subprocess.""" + logger.info(f"Starting server subprocess on {self.host}:{self.port}") + + cmd = [ + sys.executable, + "-m", + "taskweaver.ces.server", + "--host", + self.host, + "--port", + str(self.port), + "--work-dir", + self.work_dir, + ] + + if self.api_key: + cmd.extend(["--api-key", self.api_key]) + + # Environment for the subprocess + env = os.environ.copy() + env["TASKWEAVER_SERVER_HOST"] = self.host + env["TASKWEAVER_SERVER_PORT"] = str(self.port) + env["TASKWEAVER_SERVER_WORK_DIR"] = self.work_dir + if self.api_key: + env["TASKWEAVER_SERVER_API_KEY"] = self.api_key + + try: + self._process = subprocess.Popen( + cmd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + # Start in new process group to allow clean shutdown + start_new_session=True, + ) + logger.info(f"Server subprocess started with PID {self._process.pid}") + except Exception as e: + raise ServerLauncherError(f"Failed to start server subprocess: {e}") + + def _start_container(self) -> None: + """Start the server in a Docker container.""" + logger.info(f"Starting server container {self.container_image}") + + try: + import docker + import docker.errors + except ImportError: + raise ServerLauncherError( + "docker package is required for container mode. " "Please install it with: pip install docker", + ) + + try: + client = docker.from_env() + except docker.errors.DockerException as e: + raise ServerLauncherError(f"Failed to connect to Docker: {e}") + + # Ensure image exists + try: + client.images.get(self.container_image) + except docker.errors.ImageNotFound: + logger.info(f"Pulling image {self.container_image}...") + try: + client.images.pull(self.container_image) + except docker.errors.DockerException as e: + raise ServerLauncherError(f"Failed to pull image: {e}") + + # Environment variables for container + container_env = { + "TASKWEAVER_SERVER_HOST": "0.0.0.0", + "TASKWEAVER_SERVER_PORT": "8000", + "TASKWEAVER_SERVER_WORK_DIR": "/app/workspace", + } + if self.api_key: + container_env["TASKWEAVER_SERVER_API_KEY"] = self.api_key + + # Volume mapping + volumes = { + os.path.abspath(self.work_dir): {"bind": "/app/workspace", "mode": "rw"}, + } + + try: + container = client.containers.run( + image=self.container_image, + detach=True, + environment=container_env, + volumes=volumes, + ports={"8000/tcp": self.port}, + remove=True, # Auto-remove on stop + ) + self._container_id = container.id + logger.info(f"Server container started with ID {self._container_id[:12]}") + except docker.errors.DockerException as e: + raise ServerLauncherError(f"Failed to start container: {e}") + + def _wait_for_ready(self) -> None: + """Wait for the server to become ready. + + Raises: + ServerLauncherError: If server doesn't become ready in time. + """ + logger.info(f"Starting Code Execution Server at {self.server_url}") + start_time = time.time() + + while time.time() - start_time < self.startup_timeout: + if self.is_server_running(): + elapsed = time.time() - start_time + logger.info(f"Code Execution Server ready ({elapsed:.1f}s)") + return + + # Check if process/container is still alive + if self._process is not None: + poll_result = self._process.poll() + if poll_result is not None: + # Process has exited + stdout, stderr = self._process.communicate() + raise ServerLauncherError( + f"Server process exited with code {poll_result}. " + f"Stderr: {stderr.decode('utf-8', errors='replace')}", + ) + + if self._container_id is not None: + try: + import docker + + client = docker.from_env() + container = client.containers.get(self._container_id) + if container.status not in ("running", "created"): + logs = container.logs().decode("utf-8", errors="replace") + raise ServerLauncherError( + f"Container exited with status {container.status}. " f"Logs: {logs}", + ) + except Exception as e: + if "docker" not in str(type(e).__module__): + raise + + time.sleep(0.5) + + raise ServerLauncherError( + f"Server did not become ready within {self.startup_timeout} seconds", + ) + + def stop(self) -> None: + """Stop the execution server.""" + if not self._started: + return + + if self._process is not None: + self._stop_subprocess() + + if self._container_id is not None: + self._stop_container() + + self._started = False + + def _stop_subprocess(self) -> None: + """Stop the server subprocess.""" + if self._process is None: + return + + logger.info(f"Stopping server subprocess (PID {self._process.pid})") + + try: + # Try graceful shutdown first + if os.name == "nt": + # Windows + self._process.terminate() + else: + # Unix - send SIGTERM to process group + os.killpg(os.getpgid(self._process.pid), signal.SIGTERM) + + # Wait for graceful shutdown + try: + self._process.wait(timeout=10) + except subprocess.TimeoutExpired: + # Force kill + logger.warning("Server didn't stop gracefully, forcing kill") + if os.name == "nt": + self._process.kill() + else: + os.killpg(os.getpgid(self._process.pid), signal.SIGKILL) + self._process.wait(timeout=5) + + except Exception as e: + logger.error(f"Error stopping server subprocess: {e}") + finally: + self._process = None + + def _stop_container(self) -> None: + """Stop the server container.""" + if self._container_id is None: + return + + logger.info(f"Stopping server container {self._container_id[:12]}") + + try: + import docker + + client = docker.from_env() + container = client.containers.get(self._container_id) + container.stop(timeout=10) + except Exception as e: + logger.error(f"Error stopping container: {e}") + finally: + self._container_id = None + + def __enter__(self) -> "ServerLauncher": + """Context manager entry - start the server.""" + self.start() + return self + + def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: + """Context manager exit - stop the server.""" + self.stop() diff --git a/taskweaver/ces/common.py b/taskweaver/ces/common.py index 40517bd1c..d2e8685c0 100644 --- a/taskweaver/ces/common.py +++ b/taskweaver/ces/common.py @@ -36,6 +36,7 @@ class ExecutionArtifact: file_content: str = "" file_content_encoding: Literal["str", "base64"] = "str" preview: str = "" + download_url: str = "" # HTTP URL for downloading the artifact from the server @staticmethod def from_dict(d: Dict[str, str]) -> ExecutionArtifact: diff --git a/taskweaver/ces/manager/__init__.py b/taskweaver/ces/manager/__init__.py index e69de29bb..9b8f70de5 100644 --- a/taskweaver/ces/manager/__init__.py +++ b/taskweaver/ces/manager/__init__.py @@ -0,0 +1,23 @@ +"""Code Execution Service Manager package. + +This package provides manager implementations for code execution: +- ExecutionServiceProvider: HTTP server-based execution (default) +- DeferredManager: Lazy initialization wrapper +- SubProcessManager: Used internally by the server for kernel management +""" + +from taskweaver.ces.manager.defer import DeferredClient, DeferredManager +from taskweaver.ces.manager.execution_service import ExecutionServiceClient, ExecutionServiceProvider +from taskweaver.ces.manager.sub_proc import SubProcessClient, SubProcessManager + +__all__ = [ + # Internal (used by server) + "SubProcessManager", + "SubProcessClient", + # Server mode (default) + "ExecutionServiceProvider", + "ExecutionServiceClient", + # Deferred wrappers + "DeferredManager", + "DeferredClient", +] diff --git a/taskweaver/ces/manager/execution_service.py b/taskweaver/ces/manager/execution_service.py new file mode 100644 index 000000000..31f471e90 --- /dev/null +++ b/taskweaver/ces/manager/execution_service.py @@ -0,0 +1,235 @@ +"""ExecutionServiceProvider - Manager implementation for server-based execution. + +This module provides the ExecutionServiceProvider class which implements the +Manager ABC and uses the HTTP client to communicate with the execution server. +""" + +from __future__ import annotations + +import logging +import os +from typing import Callable, Dict, Optional + +from taskweaver.ces.client.execution_client import ExecutionClient +from taskweaver.ces.client.server_launcher import ServerLauncher +from taskweaver.ces.common import Client, ExecutionResult, KernelModeType, Manager + +logger = logging.getLogger(__name__) + + +class ExecutionServiceClient(Client): + """Client wrapper that manages HTTP client lifecycle. + + This class wraps ExecutionClient and handles session creation/cleanup + to implement the Client ABC interface. + """ + + def __init__( + self, + session_id: str, + server_url: str, + api_key: Optional[str] = None, + timeout: float = 300.0, + cwd: Optional[str] = None, + ) -> None: + """Initialize the execution service client. + + Args: + session_id: Unique session identifier. + server_url: URL of the execution server. + api_key: Optional API key for authentication. + timeout: Request timeout in seconds. + cwd: Optional working directory for code execution. + """ + self.session_id = session_id + self.server_url = server_url + self.api_key = api_key + self.timeout = timeout + self.cwd = cwd + self._client: Optional[ExecutionClient] = None + + def start(self) -> None: + """Start the session by creating it on the server.""" + if self._client is not None: + return + + self._client = ExecutionClient( + session_id=self.session_id, + server_url=self.server_url, + api_key=self.api_key, + timeout=self.timeout, + cwd=self.cwd, + ) + self._client.start() + + def stop(self) -> None: + """Stop the session.""" + if self._client is None: + return + + try: + self._client.stop() + finally: + self._client.close() + self._client = None + + def load_plugin( + self, + plugin_name: str, + plugin_code: str, + plugin_config: Dict[str, str], + ) -> None: + """Load a plugin into the session.""" + if self._client is None: + raise RuntimeError("Client not started") + self._client.load_plugin(plugin_name, plugin_code, plugin_config) + + def test_plugin(self, plugin_name: str) -> None: + """Test a loaded plugin.""" + if self._client is None: + raise RuntimeError("Client not started") + self._client.test_plugin(plugin_name) + + def update_session_var(self, session_var_dict: Dict[str, str]) -> None: + """Update session variables.""" + if self._client is None: + raise RuntimeError("Client not started") + self._client.update_session_var(session_var_dict) + + def execute_code( + self, + exec_id: str, + code: str, + on_output: Optional[Callable[[str, str], None]] = None, + ) -> ExecutionResult: + """Execute code in the session.""" + if self._client is None: + raise RuntimeError("Client not started") + return self._client.execute_code(exec_id, code, on_output=on_output) + + +class ExecutionServiceProvider(Manager): + """Manager implementation that uses the HTTP execution server. + + This class implements the Manager ABC and manages the server lifecycle + (auto-start if needed) and creates ExecutionServiceClient instances + for each session. + """ + + def __init__( + self, + server_url: str = "http://localhost:8000", + api_key: Optional[str] = None, + auto_start: bool = True, + container: bool = False, + container_image: Optional[str] = None, + work_dir: Optional[str] = None, + timeout: float = 300.0, + startup_timeout: float = 60.0, + kill_existing: bool = True, + ) -> None: + """Initialize the execution service provider. + + Args: + server_url: URL of the execution server. + api_key: Optional API key for authentication. + auto_start: Whether to auto-start the server if not running. + container: Whether to run the server in a Docker container. + container_image: Docker image to use (only if container=True). + work_dir: Working directory for session data. + timeout: Request timeout in seconds. + startup_timeout: Maximum time to wait for server startup. + kill_existing: Whether to kill existing server on the port before starting. + """ + self.server_url = server_url + self.api_key = api_key + self.auto_start = auto_start + self.container = container + self.container_image = container_image + self.work_dir = work_dir or os.getcwd() + self.timeout = timeout + self.startup_timeout = startup_timeout + self.kill_existing = kill_existing + + self._launcher: Optional[ServerLauncher] = None + self._initialized = False + + # Parse host and port from URL for launcher + from urllib.parse import urlparse + + parsed = urlparse(server_url) + self._host = parsed.hostname or "localhost" + self._port = parsed.port or 8000 + + def initialize(self) -> None: + """Initialize the manager and start server if needed.""" + if self._initialized: + return + + if self.auto_start: + self._launcher = ServerLauncher( + host=self._host, + port=self._port, + api_key=self.api_key, + work_dir=self.work_dir, + container=self.container, + container_image=self.container_image, + startup_timeout=self.startup_timeout, + kill_existing=self.kill_existing, + ) + self._launcher.start() + + self._initialized = True + logger.info(f"ExecutionServiceProvider initialized with server at {self.server_url}") + + def clean_up(self) -> None: + """Clean up resources and stop server if we started it.""" + if self._launcher is not None: + self._launcher.stop() + self._launcher = None + + self._initialized = False + logger.info("ExecutionServiceProvider cleaned up") + + def get_session_client( + self, + session_id: str, + env_id: Optional[str] = None, + session_dir: Optional[str] = None, + cwd: Optional[str] = None, + ) -> Client: + """Get a client for the specified session. + + Args: + session_id: Unique session identifier. + env_id: Environment ID (ignored for server mode). + session_dir: Session directory (used as cwd if cwd not specified). + cwd: Working directory for code execution. + + Returns: + ExecutionServiceClient for the session. + """ + # Ensure initialized + if not self._initialized: + self.initialize() + + # Use session_dir as cwd if cwd not specified + effective_cwd = cwd or session_dir + + return ExecutionServiceClient( + session_id=session_id, + server_url=self.server_url, + api_key=self.api_key, + timeout=self.timeout, + cwd=effective_cwd, + ) + + def get_kernel_mode(self) -> KernelModeType: + """Get the kernel mode. + + For server mode, this returns 'local' since the server handles + the actual kernel mode internally. + """ + # Server mode abstracts the kernel mode - report as 'local' + # since the kernel runs local to the server + return "local" diff --git a/taskweaver/ces/server/__init__.py b/taskweaver/ces/server/__init__.py new file mode 100644 index 000000000..348350ce7 --- /dev/null +++ b/taskweaver/ces/server/__init__.py @@ -0,0 +1,48 @@ +"""TaskWeaver Code Execution Server. + +This package provides an HTTP API for remote code execution, +wrapping the existing Environment class behind a FastAPI server. +""" + +from taskweaver.ces.server.models import ( + ArtifactModel, + CreateSessionRequest, + CreateSessionResponse, + ErrorResponse, + ExecuteCodeRequest, + ExecuteCodeResponse, + ExecuteStreamResponse, + HealthResponse, + LoadPluginRequest, + LoadPluginResponse, + OutputEvent, + ResultEvent, + SessionInfoResponse, + StopSessionResponse, + UpdateVariablesRequest, + UpdateVariablesResponse, +) +from taskweaver.ces.server.session_manager import ServerSession, ServerSessionManager + +__all__ = [ + # Models + "ArtifactModel", + "CreateSessionRequest", + "CreateSessionResponse", + "ErrorResponse", + "ExecuteCodeRequest", + "ExecuteCodeResponse", + "ExecuteStreamResponse", + "HealthResponse", + "LoadPluginRequest", + "LoadPluginResponse", + "OutputEvent", + "ResultEvent", + "SessionInfoResponse", + "StopSessionResponse", + "UpdateVariablesRequest", + "UpdateVariablesResponse", + # Session Manager + "ServerSession", + "ServerSessionManager", +] diff --git a/taskweaver/ces/server/__main__.py b/taskweaver/ces/server/__main__.py new file mode 100644 index 000000000..fabd4faf5 --- /dev/null +++ b/taskweaver/ces/server/__main__.py @@ -0,0 +1,138 @@ +"""CLI entry point for the TaskWeaver Execution Server. + +Usage: + python -m taskweaver.ces.server [OPTIONS] + +Options: + --host TEXT Host to bind to [default: localhost] + --port INTEGER Port to bind to [default: 8000] + --api-key TEXT API key for authentication (optional) + --work-dir PATH Working directory for session data [default: current dir] + --reload Enable auto-reload for development + --log-level TEXT Log level [default: info] +""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys + + +def configure_logging(level: str) -> None: + """Configure logging for the server.""" + log_level = getattr(logging, level.upper(), logging.INFO) + logging.basicConfig( + level=log_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], + ) + + +def main() -> None: + """Main entry point for the server CLI.""" + parser = argparse.ArgumentParser( + description="TaskWeaver Code Execution Server", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument( + "--host", + type=str, + default=os.getenv("TASKWEAVER_SERVER_HOST", "localhost"), + help="Host to bind to", + ) + + parser.add_argument( + "--port", + type=int, + default=int(os.getenv("TASKWEAVER_SERVER_PORT", "8000")), + help="Port to bind to", + ) + + parser.add_argument( + "--api-key", + type=str, + default=os.getenv("TASKWEAVER_SERVER_API_KEY"), + help="API key for authentication (optional for localhost)", + ) + + parser.add_argument( + "--work-dir", + type=str, + default=os.getenv("TASKWEAVER_SERVER_WORK_DIR", os.getcwd()), + help="Working directory for session data", + ) + + parser.add_argument( + "--env-id", + type=str, + default=os.getenv("TASKWEAVER_ENV_ID", "server"), + help="Environment identifier", + ) + + parser.add_argument( + "--reload", + action="store_true", + help="Enable auto-reload for development", + ) + + parser.add_argument( + "--log-level", + type=str, + default=os.getenv("TASKWEAVER_LOG_LEVEL", "info"), + choices=["debug", "info", "warning", "error", "critical"], + help="Log level", + ) + + args = parser.parse_args() + + # Configure logging + configure_logging(args.log_level) + logger = logging.getLogger(__name__) + + # Set environment variables for the app to pick up + os.environ["TASKWEAVER_SERVER_HOST"] = args.host + os.environ["TASKWEAVER_SERVER_PORT"] = str(args.port) + os.environ["TASKWEAVER_SERVER_WORK_DIR"] = args.work_dir + os.environ["TASKWEAVER_ENV_ID"] = args.env_id + + if args.api_key: + os.environ["TASKWEAVER_SERVER_API_KEY"] = args.api_key + + print() + print("=" * 60) + print(" TaskWeaver Code Execution Server") + print("=" * 60) + print(f" Host: {args.host}") + print(f" Port: {args.port}") + print(f" URL: http://{args.host}:{args.port}") + print(f" Health: http://{args.host}:{args.port}/api/v1/health") + print(f" Work Dir: {args.work_dir}") + print(f" API Key: {'configured' if args.api_key else 'not required (localhost)'}") + print("=" * 60) + print() + + logger.info("Starting TaskWeaver Execution Server") + + try: + import uvicorn + except ImportError: + logger.error( + "uvicorn is required to run the server. " "Please install it with: pip install uvicorn", + ) + sys.exit(1) + + # Run the server + uvicorn.run( + "taskweaver.ces.server.app:app", + host=args.host, + port=args.port, + reload=args.reload, + log_level=args.log_level, + ) + + +if __name__ == "__main__": + main() diff --git a/taskweaver/ces/server/app.py b/taskweaver/ces/server/app.py new file mode 100644 index 000000000..49d55cf2a --- /dev/null +++ b/taskweaver/ces/server/app.py @@ -0,0 +1,93 @@ +"""FastAPI application setup for the execution server.""" + +from __future__ import annotations + +import logging +import os +from contextlib import asynccontextmanager +from typing import AsyncGenerator, Optional + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from taskweaver.ces.server.routes import router +from taskweaver.ces.server.session_manager import ServerSessionManager + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Manage application lifecycle (startup and shutdown).""" + # Startup + logger.info("Starting TaskWeaver Execution Server") + + # Initialize session manager from app state config + work_dir = getattr(app.state, "work_dir", None) or os.getcwd() + env_id = getattr(app.state, "env_id", None) or "server" + + session_manager = ServerSessionManager( + env_id=env_id, + work_dir=work_dir, + ) + app.state.session_manager = session_manager + + logger.info(f"Session manager initialized with work_dir={work_dir}") + + yield + + # Shutdown + logger.info("Shutting down TaskWeaver Execution Server") + session_manager.cleanup_all() + + +def create_app( + api_key: Optional[str] = None, + work_dir: Optional[str] = None, + env_id: Optional[str] = None, + cors_origins: Optional[list[str]] = None, +) -> FastAPI: + """Create and configure the FastAPI application. + + Args: + api_key: Optional API key for authentication. If not provided, + authentication is disabled for localhost. + work_dir: Working directory for session data. + env_id: Environment identifier. + cors_origins: List of allowed CORS origins. Defaults to allowing all. + + Returns: + Configured FastAPI application. + """ + app = FastAPI( + title="TaskWeaver Execution Server", + description="HTTP API for remote code execution with Jupyter kernels", + version="0.1.0", + lifespan=lifespan, + ) + + # Store configuration in app state for lifespan to use + app.state.api_key = api_key or os.getenv("TASKWEAVER_SERVER_API_KEY") + app.state.work_dir = work_dir or os.getenv("TASKWEAVER_SERVER_WORK_DIR") + app.state.env_id = env_id or os.getenv("TASKWEAVER_ENV_ID") + + # Configure CORS + if cors_origins is None: + cors_origins = ["*"] # Allow all origins by default for local dev + + app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Include API routes + app.include_router(router) + + return app + + +# Default app instance for uvicorn +app = create_app() diff --git a/taskweaver/ces/server/models.py b/taskweaver/ces/server/models.py new file mode 100644 index 000000000..817018c7d --- /dev/null +++ b/taskweaver/ces/server/models.py @@ -0,0 +1,216 @@ +"""Pydantic request/response models for the execution server API.""" + +from datetime import datetime +from typing import Any, Dict, List, Literal, Optional, Tuple + +from pydantic import BaseModel, Field + +# ============================================================================= +# Request Models +# ============================================================================= + + +class CreateSessionRequest(BaseModel): + """Request to create a new execution session.""" + + session_id: str = Field(..., description="Unique session identifier") + cwd: Optional[str] = Field(None, description="Working directory for code execution") + + +class LoadPluginRequest(BaseModel): + """Request to load a plugin into a session.""" + + name: str = Field(..., description="Plugin name") + code: str = Field(..., description="Plugin source code") + config: Dict[str, Any] = Field(default_factory=dict, description="Plugin configuration") + + +class ExecuteCodeRequest(BaseModel): + """Request to execute code in a session.""" + + exec_id: str = Field(..., description="Unique execution identifier") + code: str = Field(..., description="Python code to execute") + stream: bool = Field(False, description="Enable streaming output via SSE") + + +class UpdateVariablesRequest(BaseModel): + """Request to update session variables.""" + + variables: Dict[str, str] = Field(..., description="Session variables to update") + + +# ============================================================================= +# Response Models +# ============================================================================= + + +class HealthResponse(BaseModel): + """Health check response.""" + + status: Literal["healthy"] = "healthy" + version: str = Field(..., description="Server version") + active_sessions: int = Field(..., description="Number of active sessions") + + +class CreateSessionResponse(BaseModel): + """Response after creating a session.""" + + session_id: str = Field(..., description="Session identifier") + status: Literal["created"] = "created" + cwd: str = Field(..., description="Actual working directory") + + +class StopSessionResponse(BaseModel): + """Response after stopping a session.""" + + session_id: str = Field(..., description="Session identifier") + status: Literal["stopped"] = "stopped" + + +class SessionInfoResponse(BaseModel): + """Detailed session information.""" + + session_id: str = Field(..., description="Session identifier") + status: Literal["running", "stopped"] = Field(..., description="Session status") + created_at: datetime = Field(..., description="Session creation time") + last_activity: datetime = Field(..., description="Last activity time") + loaded_plugins: List[str] = Field(default_factory=list, description="Loaded plugin names") + execution_count: int = Field(0, description="Number of executions") + cwd: str = Field(..., description="Working directory") + + +class LoadPluginResponse(BaseModel): + """Response after loading a plugin.""" + + name: str = Field(..., description="Plugin name") + status: Literal["loaded"] = "loaded" + + +class ArtifactModel(BaseModel): + """Model representing an execution artifact (image, chart, etc.).""" + + name: str = Field(..., description="Artifact name") + type: str = Field(..., description="Artifact type (image, file, chart, svg, etc.)") + mime_type: str = Field("", description="MIME type of the artifact") + original_name: str = Field("", description="Original file name") + file_name: str = Field("", description="Saved file name") + file_content: Optional[str] = Field(None, description="Base64 or string content for small files") + file_content_encoding: Optional[str] = Field(None, description="Encoding: 'str' or 'base64'") + preview: str = Field("", description="Text preview of the artifact") + download_url: Optional[str] = Field(None, description="URL to download large files") + + +class ExecuteCodeResponse(BaseModel): + """Response from synchronous code execution.""" + + execution_id: str = Field(..., description="Execution identifier") + is_success: bool = Field(..., description="Whether execution succeeded") + error: Optional[str] = Field(None, description="Error message if failed") + output: Any = Field(None, description="Execution output/result") + stdout: List[str] = Field(default_factory=list, description="Standard output lines") + stderr: List[str] = Field(default_factory=list, description="Standard error lines") + log: List[Tuple[str, str, str]] = Field(default_factory=list, description="Log entries") + artifact: List[ArtifactModel] = Field(default_factory=list, description="Generated artifacts") + variables: List[Tuple[str, str]] = Field(default_factory=list, description="Session variables") + + +class ExecuteStreamResponse(BaseModel): + """Response when streaming is enabled (returns stream URL).""" + + execution_id: str = Field(..., description="Execution identifier") + stream_url: str = Field(..., description="SSE stream URL") + + +class UpdateVariablesResponse(BaseModel): + """Response after updating session variables.""" + + status: Literal["updated"] = "updated" + variables: Dict[str, str] = Field(..., description="Updated variables") + + +class ErrorResponse(BaseModel): + """Standard error response.""" + + detail: str = Field(..., description="Error message") + + +# ============================================================================= +# SSE Event Models (for streaming) +# ============================================================================= + + +class OutputEvent(BaseModel): + """SSE event for stdout/stderr output during execution.""" + + type: Literal["stdout", "stderr"] = Field(..., description="Output stream type") + text: str = Field(..., description="Output text") + + +class ResultEvent(BaseModel): + """SSE event for final execution result.""" + + execution_id: str = Field(..., description="Execution identifier") + is_success: bool = Field(..., description="Whether execution succeeded") + error: Optional[str] = Field(None, description="Error message if failed") + output: Any = Field(None, description="Execution output/result") + stdout: List[str] = Field(default_factory=list, description="Standard output lines") + stderr: List[str] = Field(default_factory=list, description="Standard error lines") + log: List[Tuple[str, str, str]] = Field(default_factory=list, description="Log entries") + artifact: List[ArtifactModel] = Field(default_factory=list, description="Generated artifacts") + variables: List[Tuple[str, str]] = Field(default_factory=list, description="Session variables") + + +# ============================================================================= +# Utility Functions +# ============================================================================= + + +def artifact_from_execution(artifact: Any) -> ArtifactModel: + """Convert an ExecutionArtifact to ArtifactModel.""" + return ArtifactModel( + name=artifact.name, + type=artifact.type, + mime_type=artifact.mime_type, + original_name=artifact.original_name, + file_name=artifact.file_name, + file_content=artifact.file_content if artifact.file_content else None, + file_content_encoding=artifact.file_content_encoding if artifact.file_content else None, + preview=artifact.preview, + ) + + +def execution_result_to_response( + result: Any, + session_id: str, + base_url: str = "", +) -> ExecuteCodeResponse: + """Convert an ExecutionResult to ExecuteCodeResponse. + + Args: + result: ExecutionResult from Environment.execute_code() + session_id: Session identifier for constructing download URLs + base_url: Base URL for constructing artifact download URLs + + Returns: + ExecuteCodeResponse model + """ + artifacts = [] + for art in result.artifact: + artifact_model = artifact_from_execution(art) + # Always set download URL for artifacts with file_name + # The session manager saves inline artifacts to disk, so all artifacts should have file_name + if art.file_name: + artifact_model.download_url = f"{base_url}/api/v1/sessions/{session_id}/artifacts/{art.file_name}" + artifacts.append(artifact_model) + + return ExecuteCodeResponse( + execution_id=result.execution_id, + is_success=result.is_success, + error=result.error, + output=result.output, + stdout=result.stdout, + stderr=result.stderr, + log=result.log, + artifact=artifacts, + variables=result.variables, + ) diff --git a/taskweaver/ces/server/routes.py b/taskweaver/ces/server/routes.py new file mode 100644 index 000000000..7a5963be5 --- /dev/null +++ b/taskweaver/ces/server/routes.py @@ -0,0 +1,473 @@ +"""FastAPI route handlers for the execution server.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import mimetypes +import os +from typing import Any, AsyncGenerator, Dict, Optional + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import FileResponse, StreamingResponse + +from taskweaver.ces.server.models import ( + ArtifactModel, + CreateSessionRequest, + CreateSessionResponse, + ExecuteCodeRequest, + ExecuteCodeResponse, + ExecuteStreamResponse, + HealthResponse, + LoadPluginRequest, + LoadPluginResponse, + SessionInfoResponse, + StopSessionResponse, + UpdateVariablesRequest, + UpdateVariablesResponse, + execution_result_to_response, +) +from taskweaver.ces.server.session_manager import ServerSessionManager + +logger = logging.getLogger(__name__) + +# API Router with versioned prefix +router = APIRouter(prefix="/api/v1") + +# Server version +SERVER_VERSION = "0.1.0" + + +def get_session_manager(request: Request) -> ServerSessionManager: + """Dependency to get the session manager from app state.""" + return request.app.state.session_manager + + +def get_api_key(request: Request) -> Optional[str]: + """Dependency to get the configured API key from app state.""" + return getattr(request.app.state, "api_key", None) + + +def verify_api_key( + request: Request, + api_key: Optional[str] = Depends(get_api_key), +) -> None: + """Verify the API key if one is configured. + + API key is optional for localhost connections. + """ + if not api_key: + # No API key configured, allow all requests + return + + # Check if request is from localhost (API key optional) + client_host = request.client.host if request.client else None + if client_host in ("127.0.0.1", "localhost", "::1"): + # Still check API key if provided + provided_key = request.headers.get("X-API-Key") + if provided_key and provided_key != api_key: + raise HTTPException(status_code=401, detail="Invalid API key") + return + + # Non-localhost requests require API key + provided_key = request.headers.get("X-API-Key") + if not provided_key: + raise HTTPException(status_code=401, detail="API key required") + if provided_key != api_key: + raise HTTPException(status_code=401, detail="Invalid API key") + + +# ============================================================================= +# Health Check +# ============================================================================= + + +@router.get("/health", response_model=HealthResponse) +async def health_check( + session_manager: ServerSessionManager = Depends(get_session_manager), +) -> HealthResponse: + """Health check endpoint (no authentication required).""" + return HealthResponse( + status="healthy", + version=SERVER_VERSION, + active_sessions=session_manager.active_session_count, + ) + + +# ============================================================================= +# Session Management +# ============================================================================= + + +@router.post( + "/sessions", + response_model=CreateSessionResponse, + status_code=201, + dependencies=[Depends(verify_api_key)], +) +async def create_session( + request: CreateSessionRequest, + session_manager: ServerSessionManager = Depends(get_session_manager), +) -> CreateSessionResponse: + """Create a new execution session.""" + if session_manager.session_exists(request.session_id): + raise HTTPException( + status_code=409, + detail=f"Session {request.session_id} already exists", + ) + + try: + session = session_manager.create_session( + session_id=request.session_id, + cwd=request.cwd, + ) + return CreateSessionResponse( + session_id=session.session_id, + status="created", + cwd=session.cwd, + ) + except Exception as e: + logger.error(f"Failed to create session {request.session_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete( + "/sessions/{session_id}", + response_model=StopSessionResponse, + dependencies=[Depends(verify_api_key)], +) +async def stop_session( + session_id: str, + session_manager: ServerSessionManager = Depends(get_session_manager), +) -> StopSessionResponse: + """Stop and remove an execution session.""" + if not session_manager.session_exists(session_id): + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + try: + session_manager.stop_session(session_id) + return StopSessionResponse(session_id=session_id, status="stopped") + except Exception as e: + logger.error(f"Failed to stop session {session_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get( + "/sessions/{session_id}", + response_model=SessionInfoResponse, + dependencies=[Depends(verify_api_key)], +) +async def get_session_info( + session_id: str, + session_manager: ServerSessionManager = Depends(get_session_manager), +) -> SessionInfoResponse: + """Get information about a session.""" + session = session_manager.get_session(session_id) + if session is None: + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + return SessionInfoResponse( + session_id=session.session_id, + status="running", + created_at=session.created_at, + last_activity=session.last_activity, + loaded_plugins=session.loaded_plugins, + execution_count=session.execution_count, + cwd=session.cwd, + ) + + +# ============================================================================= +# Plugin Management +# ============================================================================= + + +@router.post( + "/sessions/{session_id}/plugins", + response_model=LoadPluginResponse, + dependencies=[Depends(verify_api_key)], +) +async def load_plugin( + session_id: str, + request: LoadPluginRequest, + session_manager: ServerSessionManager = Depends(get_session_manager), +) -> LoadPluginResponse: + """Load a plugin into a session.""" + if not session_manager.session_exists(session_id): + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + try: + session_manager.load_plugin( + session_id=session_id, + plugin_name=request.name, + plugin_code=request.code, + plugin_config=request.config, + ) + return LoadPluginResponse(name=request.name, status="loaded") + except Exception as e: + logger.error(f"Failed to load plugin {request.name} in session {session_id}: {e}") + raise HTTPException(status_code=400, detail=f"Failed to load plugin: {e}") + + +# ============================================================================= +# Code Execution +# ============================================================================= + + +# Store for pending streaming executions +_pending_streams: Dict[str, asyncio.Queue[Dict[str, Any]]] = {} + + +@router.post( + "/sessions/{session_id}/execute", + response_model=None, # Can return ExecuteCodeResponse or ExecuteStreamResponse + dependencies=[Depends(verify_api_key)], +) +async def execute_code( + session_id: str, + request: ExecuteCodeRequest, + http_request: Request, + session_manager: ServerSessionManager = Depends(get_session_manager), +) -> ExecuteCodeResponse | ExecuteStreamResponse: + """Execute code in a session. + + If stream=false (default), returns the full result synchronously. + If stream=true, returns a stream URL for SSE-based streaming. + """ + if not session_manager.session_exists(session_id): + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + base_url = str(http_request.base_url).rstrip("/") + + if request.stream: + # Streaming mode: set up queue and return stream URL + queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue() + stream_key = f"{session_id}:{request.exec_id}" + _pending_streams[stream_key] = queue + + # Start execution in background + asyncio.create_task( + _execute_streaming( + session_manager, + session_id, + request.exec_id, + request.code, + queue, + stream_key, + base_url, + ), + ) + stream_url = f"{base_url}/api/v1/sessions/{session_id}/execute/{request.exec_id}/stream" + return ExecuteStreamResponse( + execution_id=request.exec_id, + stream_url=stream_url, + ) + + # Synchronous execution + try: + result = await session_manager.execute_code_async( + session_id=session_id, + exec_id=request.exec_id, + code=request.code, + ) + base_url = str(http_request.base_url).rstrip("/") + return execution_result_to_response(result, session_id, base_url) + except KeyError: + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + except Exception as e: + logger.error(f"Execution failed in session {session_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +async def _execute_streaming( + session_manager: ServerSessionManager, + session_id: str, + exec_id: str, + code: str, + queue: asyncio.Queue[Dict[str, Any]], + stream_key: str, + base_url: str, +) -> None: + """Execute code and stream output to the queue.""" + loop = asyncio.get_event_loop() + + def on_output(stream_name: str, text: str) -> None: + """Callback for streaming output.""" + asyncio.run_coroutine_threadsafe( + queue.put({"event": "output", "data": {"type": stream_name, "text": text}}), + loop, + ) + + try: + result = await session_manager.execute_code_async( + session_id=session_id, + exec_id=exec_id, + code=code, + on_output=on_output, + ) + + # Convert artifacts for the response + artifacts = [] + for art in result.artifact: + artifact_model = ArtifactModel( + name=art.name, + type=art.type, + mime_type=art.mime_type, + original_name=art.original_name, + file_name=art.file_name, + file_content=art.file_content if art.file_content else None, + file_content_encoding=art.file_content_encoding if art.file_content else None, + preview=art.preview, + ) + # Set download URL for artifacts with file_name + if art.file_name: + artifact_model.download_url = f"{base_url}/api/v1/sessions/{session_id}/artifacts/{art.file_name}" + artifacts.append(artifact_model.model_dump()) + + # Send result event + await queue.put( + { + "event": "result", + "data": { + "execution_id": result.execution_id, + "is_success": result.is_success, + "error": result.error, + "output": result.output, + "stdout": result.stdout, + "stderr": result.stderr, + "log": result.log, + "artifact": artifacts, + "variables": result.variables, + }, + }, + ) + except Exception as e: + logger.error(f"Streaming execution failed: {e}") + await queue.put( + { + "event": "result", + "data": { + "execution_id": exec_id, + "is_success": False, + "error": str(e), + "output": None, + "stdout": [], + "stderr": [], + "log": [], + "artifact": [], + "variables": [], + }, + }, + ) + finally: + # Signal end of stream + await queue.put({"event": "done", "data": {}}) + # Clean up after a delay to allow client to receive final events + await asyncio.sleep(5) + _pending_streams.pop(stream_key, None) + + +@router.get( + "/sessions/{session_id}/execute/{exec_id}/stream", + dependencies=[Depends(verify_api_key)], +) +async def stream_execution( + session_id: str, + exec_id: str, +) -> StreamingResponse: + """Stream execution output via Server-Sent Events (SSE).""" + stream_key = f"{session_id}:{exec_id}" + queue = _pending_streams.get(stream_key) + + if queue is None: + raise HTTPException( + status_code=404, + detail=f"No active stream for execution {exec_id}", + ) + + async def event_generator() -> AsyncGenerator[str, None]: + """Generate SSE events from the queue.""" + while True: + try: + item = await asyncio.wait_for(queue.get(), timeout=300) + event_type = item["event"] + data = json.dumps(item["data"]) + yield f"event: {event_type}\ndata: {data}\n\n" + + if event_type == "done": + break + except asyncio.TimeoutError: + # Send keepalive + yield ": keepalive\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +# ============================================================================= +# Session Variables +# ============================================================================= + + +@router.post( + "/sessions/{session_id}/variables", + response_model=UpdateVariablesResponse, + dependencies=[Depends(verify_api_key)], +) +async def update_variables( + session_id: str, + request: UpdateVariablesRequest, + session_manager: ServerSessionManager = Depends(get_session_manager), +) -> UpdateVariablesResponse: + """Update session variables.""" + if not session_manager.session_exists(session_id): + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + try: + session_manager.update_session_variables(session_id, request.variables) + return UpdateVariablesResponse(status="updated", variables=request.variables) + except Exception as e: + logger.error(f"Failed to update variables in session {session_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Artifacts +# ============================================================================= + + +@router.get( + "/sessions/{session_id}/artifacts/{filename:path}", + dependencies=[Depends(verify_api_key)], +) +async def download_artifact( + session_id: str, + filename: str, + session_manager: ServerSessionManager = Depends(get_session_manager), +) -> FileResponse: + """Download an artifact file from a session.""" + if not session_manager.session_exists(session_id): + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + artifact_path = session_manager.get_artifact_path(session_id, filename) + if artifact_path is None: + raise HTTPException(status_code=404, detail=f"Artifact {filename} not found") + + # Determine mime type + mime_type, _ = mimetypes.guess_type(artifact_path) + if mime_type is None: + mime_type = "application/octet-stream" + + return FileResponse( + path=artifact_path, + filename=os.path.basename(filename), + media_type=mime_type, + ) diff --git a/taskweaver/ces/server/session_manager.py b/taskweaver/ces/server/session_manager.py new file mode 100644 index 000000000..2832eb3a3 --- /dev/null +++ b/taskweaver/ces/server/session_manager.py @@ -0,0 +1,380 @@ +"""Server-side session management for the execution server. + +This module provides ServerSessionManager which wraps the existing Environment +class and manages multiple execution sessions. +""" + +from __future__ import annotations + +import asyncio +import base64 +import logging +import os +import threading +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional + +from taskweaver.ces.common import ExecutionResult +from taskweaver.ces.environment import Environment, EnvMode + +logger = logging.getLogger(__name__) + + +@dataclass +class ServerSession: + """Represents a server-side execution session.""" + + session_id: str + environment: Environment + created_at: datetime = field(default_factory=datetime.utcnow) + last_activity: datetime = field(default_factory=datetime.utcnow) + loaded_plugins: List[str] = field(default_factory=list) + execution_count: int = 0 + cwd: str = "" + session_dir: str = "" + + def update_activity(self) -> None: + """Update the last activity timestamp.""" + self.last_activity = datetime.utcnow() + + +class ServerSessionManager: + """Manages multiple execution sessions on the server side. + + This class wraps the Environment class and provides session lifecycle + management for the HTTP API. + """ + + def __init__( + self, + env_id: Optional[str] = None, + work_dir: Optional[str] = None, + ) -> None: + """Initialize the session manager. + + Args: + env_id: Optional environment identifier. + work_dir: Working directory for session data. Defaults to current directory. + """ + self.env_id = env_id or os.getenv("TASKWEAVER_ENV_ID", "server") + self.work_dir = work_dir or os.getenv("TASKWEAVER_SERVER_WORK_DIR", os.getcwd()) + self._sessions: Dict[str, ServerSession] = {} + self._lock = threading.RLock() + + # Ensure work directory exists + os.makedirs(self.work_dir, exist_ok=True) + logger.info(f"ServerSessionManager initialized with work_dir={self.work_dir}") + + @property + def active_session_count(self) -> int: + """Return the number of active sessions.""" + with self._lock: + return len(self._sessions) + + def session_exists(self, session_id: str) -> bool: + """Check if a session exists.""" + with self._lock: + return session_id in self._sessions + + def get_session(self, session_id: str) -> Optional[ServerSession]: + """Get a session by ID.""" + with self._lock: + return self._sessions.get(session_id) + + def create_session( + self, + session_id: str, + cwd: Optional[str] = None, + ) -> ServerSession: + """Create a new execution session. + + Args: + session_id: Unique session identifier. + cwd: Optional working directory for code execution. + + Returns: + The created ServerSession. + + Raises: + ValueError: If session already exists. + """ + with self._lock: + if session_id in self._sessions: + raise ValueError(f"Session {session_id} already exists") + + # Create session directory structure + session_dir = os.path.join(self.work_dir, "sessions", session_id) + os.makedirs(session_dir, exist_ok=True) + + # Determine cwd + if cwd is None: + cwd = os.path.join(session_dir, "cwd") + os.makedirs(cwd, exist_ok=True) + + # Create Environment for this session (EnvMode.Local only on server) + environment = Environment( + env_id=self.env_id, + env_dir=self.work_dir, + env_mode=EnvMode.Local, + ) + + # Start the kernel session + environment.start_session( + session_id=session_id, + session_dir=session_dir, + cwd=cwd, + ) + + session = ServerSession( + session_id=session_id, + environment=environment, + cwd=cwd, + session_dir=session_dir, + ) + self._sessions[session_id] = session + + logger.info(f"Created session {session_id} with cwd={cwd}") + return session + + def stop_session(self, session_id: str) -> None: + """Stop and remove a session. + + Args: + session_id: Session identifier. + + Raises: + KeyError: If session does not exist. + """ + with self._lock: + if session_id not in self._sessions: + raise KeyError(f"Session {session_id} not found") + + session = self._sessions[session_id] + try: + session.environment.stop_session(session_id) + except Exception as e: + logger.error(f"Error stopping session {session_id}: {e}") + finally: + del self._sessions[session_id] + logger.info(f"Stopped session {session_id}") + + def load_plugin( + self, + session_id: str, + plugin_name: str, + plugin_code: str, + plugin_config: Optional[Dict[str, Any]] = None, + ) -> None: + """Load a plugin into a session. + + Args: + session_id: Session identifier. + plugin_name: Name of the plugin. + plugin_code: Plugin source code. + plugin_config: Optional plugin configuration. + + Raises: + KeyError: If session does not exist. + """ + session = self.get_session(session_id) + if session is None: + raise KeyError(f"Session {session_id} not found") + + session.environment.load_plugin( + session_id=session_id, + plugin_name=plugin_name, + plugin_impl=plugin_code, + plugin_config=plugin_config, + ) + + if plugin_name not in session.loaded_plugins: + session.loaded_plugins.append(plugin_name) + + session.update_activity() + logger.info(f"Loaded plugin {plugin_name} in session {session_id}") + + def execute_code( + self, + session_id: str, + exec_id: str, + code: str, + on_output: Optional[Callable[[str, str], None]] = None, + ) -> ExecutionResult: + """Execute code in a session. + + Args: + session_id: Session identifier. + exec_id: Execution identifier. + code: Python code to execute. + on_output: Optional callback for streaming output. + + Returns: + ExecutionResult from the code execution. + + Raises: + KeyError: If session does not exist. + """ + session = self.get_session(session_id) + if session is None: + raise KeyError(f"Session {session_id} not found") + + result = session.environment.execute_code( + session_id=session_id, + code=code, + exec_id=exec_id, + on_output=on_output, + ) + + # Save inline artifacts to disk so they can be downloaded via HTTP + self._save_inline_artifacts(session, result) + + session.execution_count += 1 + session.update_activity() + + return result + + async def execute_code_async( + self, + session_id: str, + exec_id: str, + code: str, + on_output: Optional[Callable[[str, str], None]] = None, + ) -> ExecutionResult: + """Execute code asynchronously in a session. + + This runs the synchronous execute_code in a thread pool to avoid + blocking the async event loop. + + Args: + session_id: Session identifier. + exec_id: Execution identifier. + code: Python code to execute. + on_output: Optional callback for streaming output. + + Returns: + ExecutionResult from the code execution. + """ + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: self.execute_code(session_id, exec_id, code, on_output), + ) + + def _save_inline_artifacts( + self, + session: ServerSession, + result: ExecutionResult, + ) -> None: + """Save inline artifacts (base64 content) to disk for HTTP download. + + This ensures all artifacts can be accessed via the download endpoint, + regardless of whether they were generated inline (e.g., plt.show()) + or saved to disk (e.g., plt.savefig()). + + Args: + session: The server session. + result: Execution result containing artifacts. + """ + for artifact in result.artifact: + # Skip if no inline content or already has a file_name + if not artifact.file_content: + continue + if artifact.file_name: + continue + + # Determine file extension from mime type + ext = ".bin" + if artifact.mime_type: + mime_to_ext = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/svg+xml": ".svg", + "text/html": ".html", + "application/json": ".json", + } + ext = mime_to_ext.get(artifact.mime_type, ".bin") + + # Generate filename from artifact name + file_name = f"{artifact.name}_image{ext}" + file_path = os.path.join(session.cwd, file_name) + + try: + # Decode and save the content + if artifact.file_content_encoding == "base64": + content = base64.b64decode(artifact.file_content) + with open(file_path, "wb") as f: + f.write(content) + else: + # String content (e.g., SVG) + with open(file_path, "w", encoding="utf-8") as f: + f.write(artifact.file_content) + + # Update artifact with the file_name so download URL can be constructed + artifact.file_name = file_name + artifact.original_name = file_name + logger.debug(f"Saved inline artifact to {file_path}") + + except Exception as e: + logger.warning(f"Failed to save inline artifact {artifact.name}: {e}") + + def update_session_variables( + self, + session_id: str, + variables: Dict[str, str], + ) -> None: + """Update session variables. + + Args: + session_id: Session identifier. + variables: Variables to update. + + Raises: + KeyError: If session does not exist. + """ + session = self.get_session(session_id) + if session is None: + raise KeyError(f"Session {session_id} not found") + + session.environment.update_session_var(session_id, variables) + session.update_activity() + + def get_artifact_path(self, session_id: str, filename: str) -> Optional[str]: + """Get the full path to an artifact file. + + Args: + session_id: Session identifier. + filename: Artifact filename. + + Returns: + Full path to the artifact, or None if not found. + """ + session = self.get_session(session_id) + if session is None: + return None + + # Artifacts are typically in the cwd directory + artifact_path = os.path.join(session.cwd, filename) + if os.path.isfile(artifact_path): + return artifact_path + + # Also check session_dir/cwd + artifact_path = os.path.join(session.session_dir, "cwd", filename) + if os.path.isfile(artifact_path): + return artifact_path + + return None + + def cleanup_all(self) -> None: + """Stop all sessions and clean up resources.""" + with self._lock: + session_ids = list(self._sessions.keys()) + + for session_id in session_ids: + try: + self.stop_session(session_id) + except Exception as e: + logger.error(f"Error cleaning up session {session_id}: {e}") + + logger.info("Cleaned up all sessions") diff --git a/taskweaver/chat/console/chat.py b/taskweaver/chat/console/chat.py index 3a5b410b4..3f5f7656d 100644 --- a/taskweaver/chat/console/chat.py +++ b/taskweaver/chat/console/chat.py @@ -530,9 +530,14 @@ def format_status_message(limit: int): class TaskWeaverChatApp(SessionEventHandlerBase): def __init__(self, app_dir: Optional[str] = None): - from taskweaver.app.app import TaskWeaverApp + from taskweaver.app.app import TaskWeaverApp, _cleanup_existing_servers - self.app = TaskWeaverApp(app_dir=app_dir, use_local_uri=True) + # Check and kill any existing server before starting + cleanup_result = _cleanup_existing_servers() + if cleanup_result: + click.secho(f"[Startup] Killed existing server (PID: {cleanup_result}) on port 8000", fg="yellow") + + self.app = TaskWeaverApp(app_dir=app_dir) self.session = self.app.get_session() self.pending_files: List[Dict[Literal["name", "path", "content"], Any]] = [] atexit.register(self.app.stop) diff --git a/taskweaver/code_interpreter/code_executor.py b/taskweaver/code_interpreter/code_executor.py index 499c2fbe8..b3a4d7979 100644 --- a/taskweaver/code_interpreter/code_executor.py +++ b/taskweaver/code_interpreter/code_executor.py @@ -1,5 +1,4 @@ import os -from pathlib import Path from typing import Callable, List, Literal, Optional from injector import inject @@ -15,10 +14,23 @@ TRUNCATE_CHAR_LENGTH = 1500 -def get_artifact_uri(execution_id: str, file: str, use_local_uri: bool) -> str: - return ( - Path(os.path.join("workspace", execution_id, file)).as_uri() if use_local_uri else f"http://artifact-ref/{file}" - ) +def get_artifact_uri(file_name: str, download_url: str) -> str: + """Get the URI for an artifact. + + Since all execution goes through the HTTP server, artifacts always have + download URLs available. + + Args: + file_name: The artifact file name (used as fallback). + download_url: The HTTP download URL from the server. + + Returns: + The download URL, or a placeholder if not available. + """ + if download_url: + return download_url + # Fallback - should not happen in normal operation + return f"artifact://{file_name}" def get_default_artifact_name(artifact_type: ArtifactType, mine_type: str) -> str: @@ -163,7 +175,6 @@ def format_code_output( indent: int = 0, with_code: bool = True, code_mask: Optional[str] = None, - use_local_uri: bool = False, ) -> str: lines: List[str] = [] @@ -233,17 +244,7 @@ def format_code_output( lines.extend( [ f"- type: {a.type} ; uri: " - + ( - get_artifact_uri( - execution_id=result.execution_id, - file=( - a.file_name - if os.path.isabs(a.file_name) or not use_local_uri - else os.path.join(self.execution_cwd, a.file_name) - ), - use_local_uri=use_local_uri, - ) - ) + + get_artifact_uri(a.file_name, a.download_url) + f" ; description: {a.preview}" for a in result.artifact ], diff --git a/taskweaver/code_interpreter/code_interpreter/code_interpreter.py b/taskweaver/code_interpreter/code_interpreter/code_interpreter.py index 8422d7252..9dc466f2b 100644 --- a/taskweaver/code_interpreter/code_interpreter/code_interpreter.py +++ b/taskweaver/code_interpreter/code_interpreter/code_interpreter.py @@ -1,5 +1,4 @@ import json -import os from typing import Dict, Literal, Optional from injector import inject @@ -19,13 +18,6 @@ class CodeInterpreterConfig(RoleConfig): def _configure(self): - self.use_local_uri = self._get_bool( - "use_local_uri", - self.src.get_bool( - "use_local_uri", - True, - ), - ) self.max_retry_count = self._get_int("max_retry_count", 3) # for verification @@ -283,7 +275,6 @@ def on_execution_output(stream_name: str, text: str): code_output = self.executor.format_code_output( exec_result, with_code=False, - use_local_uri=self.config.use_local_uri, code_mask=full_code_prefix, ) @@ -295,14 +286,7 @@ def on_execution_output(stream_name: str, text: str): # add artifact paths post_proxy.update_attachment( - [ - ( - a.file_name - if os.path.isabs(a.file_name) or not self.config.use_local_uri - else os.path.join(self.executor.execution_cwd, a.file_name) - ) - for a in exec_result.artifact - ], # type: ignore + [a.file_name for a in exec_result.artifact], AttachmentType.artifact_paths, ) @@ -310,7 +294,6 @@ def on_execution_output(stream_name: str, text: str): self.executor.format_code_output( exec_result, with_code=True, # the message to be sent to the user should contain the code - use_local_uri=self.config.use_local_uri, code_mask=full_code_prefix, ), is_end=True, diff --git a/taskweaver/code_interpreter/code_interpreter_cli_only/code_interpreter_cli_only.py b/taskweaver/code_interpreter/code_interpreter_cli_only/code_interpreter_cli_only.py index 22b636d00..f579e1967 100644 --- a/taskweaver/code_interpreter/code_interpreter_cli_only/code_interpreter_cli_only.py +++ b/taskweaver/code_interpreter/code_interpreter_cli_only/code_interpreter_cli_only.py @@ -16,7 +16,6 @@ class CodeInterpreterConfig(RoleConfig): def _configure(self): - self.use_local_uri = self._get_bool("use_local_uri", False) self.max_retry_count = self._get_int("max_retry_count", 3) diff --git a/taskweaver/code_interpreter/code_interpreter_plugin_only/code_interpreter_plugin_only.py b/taskweaver/code_interpreter/code_interpreter_plugin_only/code_interpreter_plugin_only.py index 41d09abf0..2cba105ac 100644 --- a/taskweaver/code_interpreter/code_interpreter_plugin_only/code_interpreter_plugin_only.py +++ b/taskweaver/code_interpreter/code_interpreter_plugin_only/code_interpreter_plugin_only.py @@ -18,13 +18,6 @@ class CodeInterpreterConfig(RoleConfig): def _configure(self): - self.use_local_uri = self._get_bool( - "use_local_uri", - self.src.get_bool( - "use_local_uri", - True, - ), - ) self.max_retry_count = self._get_int("max_retry_count", 3) @@ -131,7 +124,6 @@ def reply( code_output = self.executor.format_code_output( exec_result, with_code=True, - use_local_uri=self.config.use_local_uri, ) post_proxy.update_message( diff --git a/taskweaver/module/execution_service.py b/taskweaver/module/execution_service.py index ac46ac67b..9bc8836df 100644 --- a/taskweaver/module/execution_service.py +++ b/taskweaver/module/execution_service.py @@ -15,24 +15,42 @@ def _configure(self) -> None: "env_dir", os.path.join(self.src.app_base_path, "env"), ) - self.kernel_mode = self._get_str( - "kernel_mode", - "container", - ) - assert self.kernel_mode in ["local", "container"], f"Invalid kernel mode: {self.kernel_mode}" - if self.kernel_mode == "local": - print( - "TaskWeaver is running in the `local` mode. This implies that " - "the code execution service will run on the same machine as the TaskWeaver server. " - "For better security, it is recommended to run the code execution service in the `container` mode. " - "More information can be found in the documentation " - "(https://microsoft.github.io/TaskWeaver/docs/code_execution/).", - ) - self.custom_image = self._get_str( - "custom_image", + + # Server configuration + self.server_url = self._get_str( + "server.url", + "http://localhost:8000", + ) + self.server_api_key = self._get_str( + "server.api_key", default=None, required=False, ) + self.server_auto_start = self._get_bool( + "server.auto_start", + True, + ) + self.server_container = self._get_bool( + "server.container", + False, + ) + self.server_container_image = self._get_str( + "server.container_image", + default=None, + required=False, + ) + self.server_timeout = self._get_float( + "server.timeout", + 300.0, + ) + self.server_startup_timeout = self._get_float( + "server.startup_timeout", + 60.0, + ) + self.server_kill_existing = self._get_bool( + "server.kill_existing", + True, + ) class ExecutionServiceModule(Module): @@ -44,7 +62,13 @@ def provide_executor_manager(self, config: ExecutionServiceConfig) -> Manager: if self.manager is None: self.manager = code_execution_service_factory( env_dir=config.env_dir, - kernel_mode=config.kernel_mode, - custom_image=config.custom_image, + server_url=config.server_url, + server_api_key=config.server_api_key, + server_auto_start=config.server_auto_start, + server_container=config.server_container, + server_container_image=config.server_container_image, + server_timeout=config.server_timeout, + server_startup_timeout=config.server_startup_timeout, + server_kill_existing=config.server_kill_existing, ) return self.manager diff --git a/test-display-1_image.png b/test-display-1_image.png new file mode 100644 index 0000000000000000000000000000000000000000..e0c4eac667dca6f951f8f65c3d7c43d03fb51028 GIT binary patch literal 68823 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

A& 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ෞ!jD86fLwc+ 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 literal 0 HcmV?d00001 diff --git a/test-exec-display-1_image.png b/test-exec-display-1_image.png new file mode 100644 index 0000000000000000000000000000000000000000..e0c4eac667dca6f951f8f65c3d7c43d03fb51028 GIT binary patch literal 68823 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

A& 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ෞ!jD86fLwc+ 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 literal 0 HcmV?d00001 diff --git a/tests/unit_tests/ces/test_execution_client.py b/tests/unit_tests/ces/test_execution_client.py new file mode 100644 index 000000000..c3e83d34a --- /dev/null +++ b/tests/unit_tests/ces/test_execution_client.py @@ -0,0 +1,615 @@ +"""Unit tests for ExecutionClient.""" + +import json +from typing import Any, Dict, List +from unittest.mock import MagicMock, patch + +import pytest + +from taskweaver.ces.client.execution_client import ExecutionClient, ExecutionClientError +from taskweaver.ces.common import ExecutionResult + + +class MockResponse: + """Mock HTTP response for testing.""" + + def __init__( + self, + status_code: int = 200, + json_data: Dict[str, Any] | None = None, + text: str = "", + content: bytes = b"", + ) -> None: + self.status_code = status_code + self._json_data = json_data + self.text = text or (json.dumps(json_data) if json_data else "") + self.content = content + + def json(self) -> Dict[str, Any]: + if self._json_data is None: + raise ValueError("No JSON data") + return self._json_data + + +class TestExecutionClientInit: + """Tests for ExecutionClient initialization.""" + + def test_basic_init(self) -> None: + """Test basic client initialization.""" + client = ExecutionClient( + session_id="test-session", + server_url="http://localhost:8000", + ) + assert client.session_id == "test-session" + assert client.server_url == "http://localhost:8000" + assert client.api_key is None + assert client.timeout == 300.0 + assert client.cwd is None + assert client._started is False + + def test_init_with_trailing_slash(self) -> None: + """Test that trailing slash is stripped from server_url.""" + client = ExecutionClient( + session_id="test", + server_url="http://localhost:8000/", + ) + assert client.server_url == "http://localhost:8000" + + def test_init_with_api_key(self) -> None: + """Test initialization with API key.""" + client = ExecutionClient( + session_id="test", + server_url="http://localhost:8000", + api_key="secret-key", + ) + assert client.api_key == "secret-key" + assert client._headers["X-API-Key"] == "secret-key" + + def test_init_with_custom_timeout(self) -> None: + """Test initialization with custom timeout.""" + client = ExecutionClient( + session_id="test", + server_url="http://localhost:8000", + timeout=60.0, + ) + assert client.timeout == 60.0 + + def test_init_with_cwd(self) -> None: + """Test initialization with cwd.""" + client = ExecutionClient( + session_id="test", + server_url="http://localhost:8000", + cwd="/custom/path", + ) + assert client.cwd == "/custom/path" + + def test_api_base_property(self) -> None: + """Test api_base property.""" + client = ExecutionClient( + session_id="test", + server_url="http://localhost:8000", + ) + assert client.api_base == "http://localhost:8000/api/v1" + + +class TestExecutionClientResponseHandling: + """Tests for response handling.""" + + def test_handle_response_success(self) -> None: + """Test handling successful response.""" + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + response = MockResponse( + status_code=200, + json_data={"result": "success"}, + ) + result = client._handle_response(response) # type: ignore + assert result == {"result": "success"} + + def test_handle_response_error_with_json(self) -> None: + """Test handling error response with JSON detail.""" + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + response = MockResponse( + status_code=404, + json_data={"detail": "Session not found"}, + ) + with pytest.raises(ExecutionClientError) as exc_info: + client._handle_response(response) # type: ignore + assert exc_info.value.status_code == 404 + assert "Session not found" in str(exc_info.value) + + def test_handle_response_error_without_json(self) -> None: + """Test handling error response without JSON.""" + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + response = MockResponse( + status_code=500, + text="Internal Server Error", + ) + response._json_data = None # Force non-JSON response + + with pytest.raises(ExecutionClientError) as exc_info: + client._handle_response(response) # type: ignore + assert exc_info.value.status_code == 500 + + +class TestExecutionClientHealthCheck: + """Tests for health_check method.""" + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_health_check_success(self, mock_client_class: MagicMock) -> None: + """Test successful health check.""" + mock_client = MagicMock() + mock_client.get.return_value = MockResponse( + status_code=200, + json_data={"status": "healthy", "version": "1.0.0", "active_sessions": 3}, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + result = client.health_check() + + assert result["status"] == "healthy" + mock_client.get.assert_called_once_with("/api/v1/health") + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_health_check_connection_error(self, mock_client_class: MagicMock) -> None: + """Test health check with connection error.""" + import httpx + + mock_client = MagicMock() + mock_client.get.side_effect = httpx.ConnectError("Connection refused") + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + + with pytest.raises(ExecutionClientError) as exc_info: + client.health_check() + assert "Cannot connect" in str(exc_info.value) + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_health_check_timeout(self, mock_client_class: MagicMock) -> None: + """Test health check with timeout.""" + import httpx + + mock_client = MagicMock() + mock_client.get.side_effect = httpx.TimeoutException("Timeout") + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + + with pytest.raises(ExecutionClientError) as exc_info: + client.health_check() + assert "timeout" in str(exc_info.value).lower() + + +class TestExecutionClientSession: + """Tests for session start/stop.""" + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_start_session(self, mock_client_class: MagicMock) -> None: + """Test starting a session.""" + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={"session_id": "test", "status": "created", "cwd": "/tmp/work"}, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + client.start() + + assert client._started is True + assert client.cwd == "/tmp/work" + mock_client.post.assert_called_once_with( + "/api/v1/sessions", + json={"session_id": "test", "cwd": None}, + ) + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_start_session_already_started(self, mock_client_class: MagicMock) -> None: + """Test that start is idempotent.""" + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={"session_id": "test", "status": "created", "cwd": "/tmp/work"}, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + client.start() + client.start() # Second call should be no-op + + assert mock_client.post.call_count == 1 + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_start_session_already_exists(self, mock_client_class: MagicMock) -> None: + """Test starting session when it already exists on server.""" + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=409, + json_data={"detail": "Session already exists"}, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + client.start() # Should not raise, just mark as started + + assert client._started is True + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_stop_session(self, mock_client_class: MagicMock) -> None: + """Test stopping a session.""" + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={"session_id": "test", "status": "created", "cwd": "/tmp"}, + ) + mock_client.delete.return_value = MockResponse( + status_code=200, + json_data={"session_id": "test", "status": "stopped"}, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + client.start() + client.stop() + + assert client._started is False + mock_client.delete.assert_called_once_with("/api/v1/sessions/test") + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_stop_session_not_started(self, mock_client_class: MagicMock) -> None: + """Test that stop is no-op when not started.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + client.stop() + + mock_client.delete.assert_not_called() + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_stop_session_already_gone(self, mock_client_class: MagicMock) -> None: + """Test stopping session when it's already gone on server.""" + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={"session_id": "test", "status": "created", "cwd": "/tmp"}, + ) + mock_client.delete.return_value = MockResponse( + status_code=404, + json_data={"detail": "Session not found"}, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + client.start() + client.stop() # Should not raise + + assert client._started is False + + +class TestExecutionClientPlugin: + """Tests for plugin operations.""" + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_load_plugin(self, mock_client_class: MagicMock) -> None: + """Test loading a plugin.""" + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={"name": "test_plugin", "status": "loaded"}, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + client.load_plugin( + plugin_name="test_plugin", + plugin_code="def test(): pass", + plugin_config={"key": "value"}, + ) + + mock_client.post.assert_called_once_with( + "/api/v1/sessions/test/plugins", + json={ + "name": "test_plugin", + "code": "def test(): pass", + "config": {"key": "value"}, + }, + ) + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_test_plugin(self, mock_client_class: MagicMock) -> None: + """Test the test_plugin method (currently a no-op).""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + client.test_plugin("test_plugin") # Should not raise + + +class TestExecutionClientVariables: + """Tests for session variable operations.""" + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_update_session_var(self, mock_client_class: MagicMock) -> None: + """Test updating session variables.""" + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={"status": "updated", "variables": {"var1": "value1"}}, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + client.update_session_var({"var1": "value1"}) + + mock_client.post.assert_called_once_with( + "/api/v1/sessions/test/variables", + json={"variables": {"var1": "value1"}}, + ) + + +class TestExecutionClientExecute: + """Tests for code execution.""" + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_execute_code_sync_success(self, mock_client_class: MagicMock) -> None: + """Test synchronous code execution.""" + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={ + "execution_id": "exec-001", + "is_success": True, + "output": "Hello World", + "stdout": ["Hello World\n"], + "stderr": [], + "log": [], + "artifact": [], + "variables": [], + }, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + result = client.execute_code("exec-001", "print('Hello World')") + + assert isinstance(result, ExecutionResult) + assert result.execution_id == "exec-001" + assert result.is_success is True + assert result.output == "Hello World" + assert result.code == "print('Hello World')" + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_execute_code_sync_failure(self, mock_client_class: MagicMock) -> None: + """Test synchronous code execution with error.""" + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={ + "execution_id": "exec-001", + "is_success": False, + "error": "NameError: name 'undefined' is not defined", + "output": "", + "stdout": [], + "stderr": ["Traceback...\n"], + "log": [], + "artifact": [], + "variables": [], + }, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + result = client.execute_code("exec-001", "undefined") + + assert result.is_success is False + assert "NameError" in result.error # type: ignore + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_execute_code_with_artifacts(self, mock_client_class: MagicMock) -> None: + """Test code execution that produces artifacts.""" + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={ + "execution_id": "exec-001", + "is_success": True, + "output": "", + "stdout": [], + "stderr": [], + "log": [("INFO", "logger", "Generated chart")], + "artifact": [ + { + "name": "chart", + "type": "image", + "mime_type": "image/png", + "original_name": "chart.png", + "file_name": "chart_001.png", + "file_content": "", + "file_content_encoding": "str", + "preview": "[chart]", + }, + ], + "variables": [("x", "42")], + }, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + result = client.execute_code("exec-001", "plot()") + + assert len(result.artifact) == 1 + assert result.artifact[0].name == "chart" + assert result.artifact[0].type == "image" + assert len(result.log) == 1 + assert len(result.variables) == 1 + assert result.variables[0] == ("x", "42") + + +class TestExecutionClientStreaming: + """Tests for streaming code execution.""" + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_execute_code_streaming(self, mock_client_class: MagicMock) -> None: + """Test streaming code execution.""" + mock_client = MagicMock() + + # Mock the initial POST to get stream URL + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={ + "execution_id": "exec-001", + "stream_url": "/api/v1/sessions/test/stream/exec-001", + }, + ) + + # Mock the SSE stream + sse_lines = [ + "event:output", + 'data:{"type":"stdout","text":"Hello\\n"}', + "", + "event:output", + 'data:{"type":"stdout","text":"World\\n"}', + "", + "event:result", + ( + 'data:{"execution_id":"exec-001","is_success":true,"output":"","' + 'stdout":["Hello\\n","World\\n"],"stderr":[],"log":[],"artifact":[],"variables":[]}' + ), + "", + "event:done", + "data:{}", + ] + + mock_stream = MagicMock() + mock_stream.__enter__ = MagicMock(return_value=mock_stream) + mock_stream.__exit__ = MagicMock(return_value=None) + mock_stream.iter_lines.return_value = iter(sse_lines) + mock_client.stream.return_value = mock_stream + mock_client_class.return_value = mock_client + + output_calls: List[tuple[str, str]] = [] + + def on_output(stream: str, text: str) -> None: + output_calls.append((stream, text)) + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + result = client.execute_code("exec-001", "print('Hello')\nprint('World')", on_output=on_output) + + assert result.is_success is True + assert len(output_calls) == 2 + assert output_calls[0] == ("stdout", "Hello\n") + assert output_calls[1] == ("stdout", "World\n") + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_execute_code_streaming_no_result(self, mock_client_class: MagicMock) -> None: + """Test streaming execution when no result is received.""" + mock_client = MagicMock() + + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={ + "execution_id": "exec-001", + "stream_url": "/api/v1/sessions/test/stream/exec-001", + }, + ) + + # Mock stream that ends without result + sse_lines = [ + "event:output", + 'data:{"type":"stdout","text":"partial output"}', + "", + "event:done", + "data:{}", + ] + + mock_stream = MagicMock() + mock_stream.__enter__ = MagicMock(return_value=mock_stream) + mock_stream.__exit__ = MagicMock(return_value=None) + mock_stream.iter_lines.return_value = iter(sse_lines) + mock_client.stream.return_value = mock_stream + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + + with pytest.raises(ExecutionClientError, match="No result received"): + client.execute_code("exec-001", "code", on_output=lambda s, t: None) + + +class TestExecutionClientArtifacts: + """Tests for artifact download.""" + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_download_artifact(self, mock_client_class: MagicMock) -> None: + """Test downloading an artifact.""" + mock_client = MagicMock() + mock_client.get.return_value = MockResponse( + status_code=200, + content=b"fake image data", + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + content = client.download_artifact("chart.png") + + assert content == b"fake image data" + mock_client.get.assert_called_once_with("/api/v1/sessions/test/artifacts/chart.png") + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_download_artifact_not_found(self, mock_client_class: MagicMock) -> None: + """Test downloading non-existent artifact.""" + mock_client = MagicMock() + mock_client.get.return_value = MockResponse( + status_code=404, + text="Not found", + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + + with pytest.raises(ExecutionClientError) as exc_info: + client.download_artifact("nonexistent.png") + assert exc_info.value.status_code == 404 + + +class TestExecutionClientContextManager: + """Tests for context manager protocol.""" + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_context_manager(self, mock_client_class: MagicMock) -> None: + """Test using client as context manager.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + with ExecutionClient(session_id="test", server_url="http://localhost:8000") as client: + assert isinstance(client, ExecutionClient) + + mock_client.close.assert_called_once() + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_close(self, mock_client_class: MagicMock) -> None: + """Test explicit close.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + client.close() + + mock_client.close.assert_called_once() + + +class TestExecutionClientError: + """Tests for ExecutionClientError exception.""" + + def test_error_with_message(self) -> None: + """Test error with message only.""" + error = ExecutionClientError("Something went wrong") + assert str(error) == "Something went wrong" + assert error.status_code is None + + def test_error_with_status_code(self) -> None: + """Test error with message and status code.""" + error = ExecutionClientError("Not found", status_code=404) + assert str(error) == "Not found" + assert error.status_code == 404 diff --git a/tests/unit_tests/ces/test_execution_service.py b/tests/unit_tests/ces/test_execution_service.py new file mode 100644 index 000000000..418b27067 --- /dev/null +++ b/tests/unit_tests/ces/test_execution_service.py @@ -0,0 +1,552 @@ +"""Unit tests for ExecutionServiceProvider and ExecutionServiceClient.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from taskweaver.ces.common import Client, ExecutionResult +from taskweaver.ces.manager.execution_service import ExecutionServiceClient, ExecutionServiceProvider + + +class TestExecutionServiceClient: + """Tests for ExecutionServiceClient.""" + + def test_init(self) -> None: + """Test client initialization.""" + client = ExecutionServiceClient( + session_id="test-session", + server_url="http://localhost:8000", + api_key="secret", + timeout=120.0, + cwd="/custom/path", + ) + assert client.session_id == "test-session" + assert client.server_url == "http://localhost:8000" + assert client.api_key == "secret" + assert client.timeout == 120.0 + assert client.cwd == "/custom/path" + assert client._client is None + + def test_implements_client_abc(self) -> None: + """Test that ExecutionServiceClient implements Client ABC.""" + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + assert isinstance(client, Client) + + @patch("taskweaver.ces.manager.execution_service.ExecutionClient") + def test_start(self, mock_client_class: MagicMock) -> None: + """Test starting the client.""" + mock_exec_client = MagicMock() + mock_client_class.return_value = mock_exec_client + + client = ExecutionServiceClient( + session_id="test-session", + server_url="http://localhost:8000", + api_key="secret", + timeout=120.0, + cwd="/custom/path", + ) + client.start() + + mock_client_class.assert_called_once_with( + session_id="test-session", + server_url="http://localhost:8000", + api_key="secret", + timeout=120.0, + cwd="/custom/path", + ) + mock_exec_client.start.assert_called_once() + assert client._client is mock_exec_client + + @patch("taskweaver.ces.manager.execution_service.ExecutionClient") + def test_start_idempotent(self, mock_client_class: MagicMock) -> None: + """Test that start is idempotent.""" + mock_exec_client = MagicMock() + mock_client_class.return_value = mock_exec_client + + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + client.start() + client.start() # Second call should be no-op + + assert mock_client_class.call_count == 1 + + @patch("taskweaver.ces.manager.execution_service.ExecutionClient") + def test_stop(self, mock_client_class: MagicMock) -> None: + """Test stopping the client.""" + mock_exec_client = MagicMock() + mock_client_class.return_value = mock_exec_client + + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + client.start() + client.stop() + + mock_exec_client.stop.assert_called_once() + mock_exec_client.close.assert_called_once() + assert client._client is None + + @patch("taskweaver.ces.manager.execution_service.ExecutionClient") + def test_stop_not_started(self, mock_client_class: MagicMock) -> None: + """Test stop when not started is no-op.""" + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + client.stop() # Should not raise + + mock_client_class.assert_not_called() + + @patch("taskweaver.ces.manager.execution_service.ExecutionClient") + def test_stop_cleans_up_on_error(self, mock_client_class: MagicMock) -> None: + """Test that stop cleans up even if stop() raises.""" + mock_exec_client = MagicMock() + mock_exec_client.stop.side_effect = Exception("Stop failed") + mock_client_class.return_value = mock_exec_client + + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + client.start() + client.stop() + + # Client should still be cleaned up + assert client._client is None + mock_exec_client.close.assert_called_once() + + @patch("taskweaver.ces.manager.execution_service.ExecutionClient") + def test_load_plugin(self, mock_client_class: MagicMock) -> None: + """Test loading a plugin.""" + mock_exec_client = MagicMock() + mock_client_class.return_value = mock_exec_client + + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + client.start() + client.load_plugin("test_plugin", "def test(): pass", {"key": "value"}) + + mock_exec_client.load_plugin.assert_called_once_with( + "test_plugin", + "def test(): pass", + {"key": "value"}, + ) + + def test_load_plugin_not_started(self) -> None: + """Test that load_plugin raises when not started.""" + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + + with pytest.raises(RuntimeError, match="Client not started"): + client.load_plugin("test", "code", {}) + + @patch("taskweaver.ces.manager.execution_service.ExecutionClient") + def test_test_plugin(self, mock_client_class: MagicMock) -> None: + """Test the test_plugin method.""" + mock_exec_client = MagicMock() + mock_client_class.return_value = mock_exec_client + + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + client.start() + client.test_plugin("test_plugin") + + mock_exec_client.test_plugin.assert_called_once_with("test_plugin") + + def test_test_plugin_not_started(self) -> None: + """Test that test_plugin raises when not started.""" + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + + with pytest.raises(RuntimeError, match="Client not started"): + client.test_plugin("test") + + @patch("taskweaver.ces.manager.execution_service.ExecutionClient") + def test_update_session_var(self, mock_client_class: MagicMock) -> None: + """Test updating session variables.""" + mock_exec_client = MagicMock() + mock_client_class.return_value = mock_exec_client + + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + client.start() + client.update_session_var({"var1": "value1"}) + + mock_exec_client.update_session_var.assert_called_once_with({"var1": "value1"}) + + def test_update_session_var_not_started(self) -> None: + """Test that update_session_var raises when not started.""" + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + + with pytest.raises(RuntimeError, match="Client not started"): + client.update_session_var({"var": "val"}) + + @patch("taskweaver.ces.manager.execution_service.ExecutionClient") + def test_execute_code(self, mock_client_class: MagicMock) -> None: + """Test executing code.""" + mock_exec_client = MagicMock() + mock_result = ExecutionResult( + execution_id="exec-001", + code="print('hello')", + is_success=True, + ) + mock_exec_client.execute_code.return_value = mock_result + mock_client_class.return_value = mock_exec_client + + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + client.start() + result = client.execute_code("exec-001", "print('hello')") + + assert result == mock_result + mock_exec_client.execute_code.assert_called_once_with( + "exec-001", + "print('hello')", + on_output=None, + ) + + @patch("taskweaver.ces.manager.execution_service.ExecutionClient") + def test_execute_code_with_callback(self, mock_client_class: MagicMock) -> None: + """Test executing code with output callback.""" + mock_exec_client = MagicMock() + mock_result = ExecutionResult( + execution_id="exec-001", + code="print('hello')", + is_success=True, + ) + mock_exec_client.execute_code.return_value = mock_result + mock_client_class.return_value = mock_exec_client + + def on_output(stream: str, text: str) -> None: + pass + + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + client.start() + client.execute_code("exec-001", "print('hello')", on_output=on_output) + + mock_exec_client.execute_code.assert_called_once_with( + "exec-001", + "print('hello')", + on_output=on_output, + ) + + def test_execute_code_not_started(self) -> None: + """Test that execute_code raises when not started.""" + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + + with pytest.raises(RuntimeError, match="Client not started"): + client.execute_code("exec-001", "code") + + +class TestExecutionServiceProvider: + """Tests for ExecutionServiceProvider.""" + + def test_init_default(self) -> None: + """Test default initialization.""" + provider = ExecutionServiceProvider() + + assert provider.server_url == "http://localhost:8000" + assert provider.api_key is None + assert provider.auto_start is True + assert provider.container is False + assert provider.timeout == 300.0 + assert provider._initialized is False + assert provider._launcher is None + + def test_init_custom(self) -> None: + """Test custom initialization.""" + provider = ExecutionServiceProvider( + server_url="http://custom:9000", + api_key="secret", + auto_start=False, + container=True, + container_image="custom/image", + work_dir="/custom/path", + timeout=120.0, + startup_timeout=30.0, + ) + + assert provider.server_url == "http://custom:9000" + assert provider.api_key == "secret" + assert provider.auto_start is False + assert provider.container is True + assert provider.container_image == "custom/image" + assert provider.work_dir == "/custom/path" + assert provider.timeout == 120.0 + assert provider.startup_timeout == 30.0 + + def test_url_parsing(self) -> None: + """Test that host and port are parsed from URL.""" + provider = ExecutionServiceProvider(server_url="http://myhost:9000") + + assert provider._host == "myhost" + assert provider._port == 9000 + + def test_url_parsing_default_port(self) -> None: + """Test default port when not in URL.""" + provider = ExecutionServiceProvider(server_url="http://myhost") + + assert provider._host == "myhost" + assert provider._port == 8000 + + @patch("taskweaver.ces.manager.execution_service.ServerLauncher") + def test_initialize_with_auto_start(self, mock_launcher_class: MagicMock) -> None: + """Test initialization with auto_start enabled.""" + mock_launcher = MagicMock() + mock_launcher_class.return_value = mock_launcher + + provider = ExecutionServiceProvider( + server_url="http://localhost:8000", + auto_start=True, + api_key="secret", + work_dir="/work", + container=True, + container_image="custom/image", + startup_timeout=30.0, + ) + provider.initialize() + + mock_launcher_class.assert_called_once_with( + host="localhost", + port=8000, + api_key="secret", + work_dir="/work", + container=True, + container_image="custom/image", + startup_timeout=30.0, + ) + mock_launcher.start.assert_called_once() + assert provider._initialized is True + + def test_initialize_without_auto_start(self) -> None: + """Test initialization without auto_start.""" + provider = ExecutionServiceProvider(auto_start=False) + provider.initialize() + + assert provider._initialized is True + assert provider._launcher is None + + @patch("taskweaver.ces.manager.execution_service.ServerLauncher") + def test_initialize_idempotent(self, mock_launcher_class: MagicMock) -> None: + """Test that initialize is idempotent.""" + mock_launcher = MagicMock() + mock_launcher_class.return_value = mock_launcher + + provider = ExecutionServiceProvider(auto_start=True) + provider.initialize() + provider.initialize() # Second call should be no-op + + assert mock_launcher_class.call_count == 1 + + @patch("taskweaver.ces.manager.execution_service.ServerLauncher") + def test_clean_up(self, mock_launcher_class: MagicMock) -> None: + """Test clean_up method.""" + mock_launcher = MagicMock() + mock_launcher_class.return_value = mock_launcher + + provider = ExecutionServiceProvider(auto_start=True) + provider.initialize() + provider.clean_up() + + mock_launcher.stop.assert_called_once() + assert provider._initialized is False + assert provider._launcher is None + + def test_clean_up_without_launcher(self) -> None: + """Test clean_up when no launcher exists.""" + provider = ExecutionServiceProvider(auto_start=False) + provider.initialize() + provider.clean_up() # Should not raise + + assert provider._initialized is False + + @patch("taskweaver.ces.manager.execution_service.ServerLauncher") + def test_get_session_client(self, mock_launcher_class: MagicMock) -> None: + """Test getting a session client.""" + mock_launcher = MagicMock() + mock_launcher_class.return_value = mock_launcher + + provider = ExecutionServiceProvider( + server_url="http://localhost:8000", + api_key="secret", + timeout=120.0, + ) + provider.initialize() + + client = provider.get_session_client( + session_id="test-session", + cwd="/custom/path", + ) + + assert isinstance(client, ExecutionServiceClient) + assert client.session_id == "test-session" + assert client.server_url == "http://localhost:8000" + assert client.api_key == "secret" + assert client.timeout == 120.0 + assert client.cwd == "/custom/path" + + @patch("taskweaver.ces.manager.execution_service.ServerLauncher") + def test_get_session_client_uses_session_dir_as_cwd( + self, + mock_launcher_class: MagicMock, + ) -> None: + """Test that session_dir is used as cwd when cwd not specified.""" + mock_launcher = MagicMock() + mock_launcher_class.return_value = mock_launcher + + provider = ExecutionServiceProvider() + provider.initialize() + + client = provider.get_session_client( + session_id="test", + session_dir="/session/dir", + ) + + assert client.cwd == "/session/dir" + + @patch("taskweaver.ces.manager.execution_service.ServerLauncher") + def test_get_session_client_cwd_overrides_session_dir( + self, + mock_launcher_class: MagicMock, + ) -> None: + """Test that cwd takes precedence over session_dir.""" + mock_launcher = MagicMock() + mock_launcher_class.return_value = mock_launcher + + provider = ExecutionServiceProvider() + provider.initialize() + + client = provider.get_session_client( + session_id="test", + session_dir="/session/dir", + cwd="/explicit/cwd", + ) + + assert client.cwd == "/explicit/cwd" + + @patch("taskweaver.ces.manager.execution_service.ServerLauncher") + def test_get_session_client_auto_initializes( + self, + mock_launcher_class: MagicMock, + ) -> None: + """Test that get_session_client auto-initializes if needed.""" + mock_launcher = MagicMock() + mock_launcher_class.return_value = mock_launcher + + provider = ExecutionServiceProvider(auto_start=True) + # Don't call initialize() + + client = provider.get_session_client("test-session") + + # Should have auto-initialized + assert provider._initialized is True + assert client is not None + + def test_get_kernel_mode(self) -> None: + """Test get_kernel_mode returns 'local'.""" + provider = ExecutionServiceProvider() + + # Server mode always reports as 'local' since kernel is local to server + assert provider.get_kernel_mode() == "local" + + +class TestExecutionServiceProviderManager: + """Tests for Manager ABC compliance.""" + + def test_implements_manager_abc(self) -> None: + """Test that ExecutionServiceProvider implements Manager ABC.""" + from taskweaver.ces.common import Manager + + provider = ExecutionServiceProvider() + assert isinstance(provider, Manager) + + @patch("taskweaver.ces.manager.execution_service.ServerLauncher") + def test_full_lifecycle(self, mock_launcher_class: MagicMock) -> None: + """Test full provider lifecycle.""" + mock_launcher = MagicMock() + mock_launcher_class.return_value = mock_launcher + + provider = ExecutionServiceProvider(auto_start=True) + + # Initialize + provider.initialize() + assert provider._initialized is True + + # Get session client + client = provider.get_session_client("test-session") + assert isinstance(client, Client) + + # Clean up + provider.clean_up() + assert provider._initialized is False + + @patch("taskweaver.ces.manager.execution_service.ExecutionClient") + @patch("taskweaver.ces.manager.execution_service.ServerLauncher") + def test_client_session_lifecycle( + self, + mock_launcher_class: MagicMock, + mock_exec_client_class: MagicMock, + ) -> None: + """Test complete client session lifecycle through provider.""" + mock_launcher = MagicMock() + mock_launcher_class.return_value = mock_launcher + + mock_exec_client = MagicMock() + mock_result = ExecutionResult( + execution_id="exec-001", + code="x = 42", + is_success=True, + output="42", + ) + mock_exec_client.execute_code.return_value = mock_result + mock_exec_client_class.return_value = mock_exec_client + + # Create provider and get client + provider = ExecutionServiceProvider(auto_start=True) + provider.initialize() + + client = provider.get_session_client("test-session", cwd="/work") + + # Use client + client.start() + result = client.execute_code("exec-001", "x = 42") + assert result.is_success is True + assert result.output == "42" + + client.stop() + + # Clean up provider + provider.clean_up() + + # Verify lifecycle calls + mock_launcher.start.assert_called_once() + mock_launcher.stop.assert_called_once() + mock_exec_client.start.assert_called_once() + mock_exec_client.stop.assert_called_once() diff --git a/tests/unit_tests/ces/test_server_launcher.py b/tests/unit_tests/ces/test_server_launcher.py new file mode 100644 index 000000000..b5f93be6a --- /dev/null +++ b/tests/unit_tests/ces/test_server_launcher.py @@ -0,0 +1,596 @@ +"""Unit tests for ServerLauncher.""" + +import os +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from taskweaver.ces.client.server_launcher import ServerLauncher, ServerLauncherError + + +class TestServerLauncherInit: + """Tests for ServerLauncher initialization.""" + + def test_default_init(self) -> None: + """Test default initialization values.""" + launcher = ServerLauncher() + assert launcher.host == "localhost" + assert launcher.port == 8000 + assert launcher.api_key is None + assert launcher.container is False + assert launcher.startup_timeout == 60.0 + assert launcher._started is False + assert launcher._process is None + assert launcher._container_id is None + + def test_init_with_custom_values(self) -> None: + """Test initialization with custom values.""" + launcher = ServerLauncher( + host="0.0.0.0", + port=9000, + api_key="secret", + work_dir="/custom/path", + container=True, + container_image="custom/image:latest", + startup_timeout=120.0, + ) + assert launcher.host == "0.0.0.0" + assert launcher.port == 9000 + assert launcher.api_key == "secret" + assert launcher.work_dir == "/custom/path" + assert launcher.container is True + assert launcher.container_image == "custom/image:latest" + assert launcher.startup_timeout == 120.0 + + def test_server_url_property(self) -> None: + """Test server_url property.""" + launcher = ServerLauncher(host="localhost", port=8000) + assert launcher.server_url == "http://localhost:8000" + + launcher = ServerLauncher(host="0.0.0.0", port=9000) + assert launcher.server_url == "http://0.0.0.0:9000" + + def test_default_work_dir(self) -> None: + """Test that work_dir defaults to cwd.""" + launcher = ServerLauncher() + assert launcher.work_dir == os.getcwd() + + def test_default_container_image(self) -> None: + """Test default container image.""" + launcher = ServerLauncher(container=True) + assert launcher.container_image == ServerLauncher.DEFAULT_IMAGE + + +class TestServerLauncherIsRunning: + """Tests for is_server_running method.""" + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + def test_server_running(self, mock_get: MagicMock) -> None: + """Test detecting running server.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_get.return_value = mock_response + + launcher = ServerLauncher() + assert launcher.is_server_running() is True + + mock_get.assert_called_once() + call_args = mock_get.call_args + assert "/api/v1/health" in call_args[0][0] + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + def test_server_running_with_api_key(self, mock_get: MagicMock) -> None: + """Test health check includes API key header.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_get.return_value = mock_response + + launcher = ServerLauncher(api_key="secret") + launcher.is_server_running() + + call_kwargs = mock_get.call_args[1] + assert call_kwargs["headers"]["X-API-Key"] == "secret" + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + def test_server_not_running_connection_error(self, mock_get: MagicMock) -> None: + """Test detecting server not running (connection error).""" + mock_get.side_effect = Exception("Connection refused") + + launcher = ServerLauncher() + assert launcher.is_server_running() is False + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + def test_server_not_running_bad_status(self, mock_get: MagicMock) -> None: + """Test detecting server not running (bad status code).""" + mock_response = MagicMock() + mock_response.status_code = 500 + mock_get.return_value = mock_response + + launcher = ServerLauncher() + assert launcher.is_server_running() is False + + +class TestServerLauncherStartSubprocess: + """Tests for subprocess-based server start.""" + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + def test_start_when_already_running(self, mock_get: MagicMock) -> None: + """Test start is no-op when server already running.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_get.return_value = mock_response + + launcher = ServerLauncher() + launcher.start() + + assert launcher._started is True + assert launcher._process is None # No subprocess created + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + @patch("subprocess.Popen") + def test_start_subprocess(self, mock_popen: MagicMock, mock_get: MagicMock) -> None: + """Test starting server as subprocess.""" + # First call: not running, subsequent calls: running + mock_get.side_effect = [ + Exception("Not running"), + MagicMock(status_code=200), + ] + + mock_process = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = None # Process still running + mock_popen.return_value = mock_process + + launcher = ServerLauncher( + host="127.0.0.1", + port=8080, + work_dir="/tmp/work", + ) + launcher.start() + + assert launcher._started is True + assert launcher._process is mock_process + + # Verify Popen was called with correct arguments + call_args = mock_popen.call_args + cmd = call_args[0][0] + assert "-m" in cmd + assert "taskweaver.ces.server" in cmd + assert "--host" in cmd + assert "127.0.0.1" in cmd + assert "--port" in cmd + assert "8080" in cmd + assert "--work-dir" in cmd + assert "/tmp/work" in cmd + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + @patch("subprocess.Popen") + def test_start_subprocess_with_api_key( + self, + mock_popen: MagicMock, + mock_get: MagicMock, + ) -> None: + """Test subprocess includes API key argument.""" + mock_get.side_effect = [ + Exception("Not running"), + MagicMock(status_code=200), + ] + mock_process = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + launcher = ServerLauncher(api_key="secret-key") + launcher.start() + + call_args = mock_popen.call_args + cmd = call_args[0][0] + assert "--api-key" in cmd + assert "secret-key" in cmd + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + @patch("subprocess.Popen") + def test_start_subprocess_failure( + self, + mock_popen: MagicMock, + mock_get: MagicMock, + ) -> None: + """Test handling subprocess start failure.""" + mock_get.side_effect = Exception("Not running") + mock_popen.side_effect = OSError("Cannot execute") + + launcher = ServerLauncher() + + with pytest.raises(ServerLauncherError, match="Failed to start"): + launcher.start() + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + @patch("subprocess.Popen") + def test_start_already_started( + self, + mock_popen: MagicMock, + mock_get: MagicMock, + ) -> None: + """Test start is idempotent when already started.""" + mock_get.side_effect = [ + Exception("Not running"), + MagicMock(status_code=200), + ] + mock_process = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + launcher = ServerLauncher() + launcher.start() + launcher.start() # Second call should be no-op + + assert mock_popen.call_count == 1 + + +class TestServerLauncherStartContainer: + """Tests for container-based server start.""" + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + def test_start_container_missing_docker(self, mock_get: MagicMock) -> None: + """Test error when docker package not installed.""" + mock_get.side_effect = Exception("Not running") + + with patch.dict("sys.modules", {"docker": None}): + launcher = ServerLauncher(container=True) + + with pytest.raises(ServerLauncherError, match="docker package is required"): + launcher.start() + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + def test_start_container_docker_not_running(self, mock_get: MagicMock) -> None: + """Test error when Docker daemon not running.""" + mock_get.side_effect = Exception("Not running") + + # Mock docker module + mock_docker = MagicMock() + mock_docker.errors.DockerException = Exception + mock_docker.from_env.side_effect = Exception("Cannot connect to Docker") + + with patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker.errors}): + launcher = ServerLauncher(container=True) + + with pytest.raises(ServerLauncherError, match="Failed to connect to Docker"): + launcher.start() + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + def test_start_container_image_not_found(self, mock_get: MagicMock) -> None: + """Test pulling image when not found locally.""" + mock_get.side_effect = [ + Exception("Not running"), + MagicMock(status_code=200), + ] + + # Mock docker module + mock_docker = MagicMock() + mock_docker.errors.DockerException = Exception + mock_docker.errors.ImageNotFound = Exception + + mock_client = MagicMock() + mock_client.images.get.side_effect = Exception("Image not found") + mock_client.images.pull.return_value = MagicMock() + + mock_container = MagicMock() + mock_container.id = "abc123def456" # pragma: allowlist secret + mock_container.status = "running" + mock_client.containers.run.return_value = mock_container + mock_client.containers.get.return_value = mock_container + + mock_docker.from_env.return_value = mock_client + + with patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker.errors}): + launcher = ServerLauncher(container=True) + launcher.start() + + mock_client.images.pull.assert_called_once() + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + def test_start_container_success(self, mock_get: MagicMock) -> None: + """Test successful container start.""" + mock_get.side_effect = [ + Exception("Not running"), + MagicMock(status_code=200), + ] + + mock_docker = MagicMock() + mock_docker.errors.DockerException = Exception + mock_docker.errors.ImageNotFound = type("ImageNotFound", (Exception,), {}) + + mock_client = MagicMock() + mock_client.images.get.return_value = MagicMock() # Image exists + + mock_container = MagicMock() + mock_container.id = "abc123def456" # pragma: allowlist secret + mock_container.status = "running" + mock_client.containers.run.return_value = mock_container + mock_client.containers.get.return_value = mock_container + + mock_docker.from_env.return_value = mock_client + + with patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker.errors}): + launcher = ServerLauncher( + container=True, + port=9000, + api_key="secret", + work_dir="/tmp/work", + ) + launcher.start() + + assert launcher._started is True + assert launcher._container_id == "abc123def456" # pragma: allowlist secret + + # Verify container run arguments + call_kwargs = mock_client.containers.run.call_args[1] + assert call_kwargs["detach"] is True + assert call_kwargs["remove"] is True + assert "9000/tcp" in call_kwargs["ports"] + assert call_kwargs["environment"]["TASKWEAVER_SERVER_API_KEY"] == "secret" + + +class TestServerLauncherWaitForReady: + """Tests for _wait_for_ready method.""" + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + @patch("subprocess.Popen") + def test_wait_for_ready_success( + self, + mock_popen: MagicMock, + mock_get: MagicMock, + ) -> None: + """Test waiting for server to become ready.""" + # Server becomes ready after first check + mock_get.side_effect = [ + Exception("Not running"), + MagicMock(status_code=200), + ] + mock_process = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + launcher = ServerLauncher() + launcher.start() + + assert launcher._started is True + + @patch("taskweaver.ces.client.server_launcher.time.sleep") + @patch("taskweaver.ces.client.server_launcher.time.time") + @patch("taskweaver.ces.client.server_launcher.httpx.get") + @patch("subprocess.Popen") + def test_wait_for_ready_timeout( + self, + mock_popen: MagicMock, + mock_get: MagicMock, + mock_time: MagicMock, + mock_sleep: MagicMock, + ) -> None: + """Test timeout waiting for server.""" + mock_get.side_effect = Exception("Not running") + + # Simulate time passing beyond timeout + mock_time.side_effect = [0, 0, 10, 20, 30, 40, 50, 60, 70] # Exceeds 60s timeout + + mock_process = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + launcher = ServerLauncher(startup_timeout=60.0) + + with pytest.raises(ServerLauncherError, match="did not become ready"): + launcher.start() + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + @patch("subprocess.Popen") + def test_wait_for_ready_process_exited( + self, + mock_popen: MagicMock, + mock_get: MagicMock, + ) -> None: + """Test handling when process exits during startup.""" + mock_get.side_effect = Exception("Not running") + + mock_process = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = 1 # Process exited with error + mock_process.communicate.return_value = (b"", b"Error: startup failed") + mock_popen.return_value = mock_process + + launcher = ServerLauncher() + + with pytest.raises(ServerLauncherError, match="process exited"): + launcher.start() + + +class TestServerLauncherStop: + """Tests for server stop.""" + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + @patch("subprocess.Popen") + def test_stop_subprocess( + self, + mock_popen: MagicMock, + mock_get: MagicMock, + ) -> None: + """Test stopping subprocess.""" + mock_get.side_effect = [ + Exception("Not running"), + MagicMock(status_code=200), + ] + mock_process = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = None + mock_process.wait.return_value = 0 + mock_popen.return_value = mock_process + + launcher = ServerLauncher() + launcher.start() + launcher.stop() + + assert launcher._started is False + assert launcher._process is None + mock_process.terminate.assert_called() + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + @patch("subprocess.Popen") + @patch("os.name", "posix") + @patch("os.killpg") + @patch("os.getpgid") + def test_stop_subprocess_unix( + self, + mock_getpgid: MagicMock, + mock_killpg: MagicMock, + mock_popen: MagicMock, + mock_get: MagicMock, + ) -> None: + """Test stopping subprocess on Unix (sends to process group).""" + mock_get.side_effect = [ + Exception("Not running"), + MagicMock(status_code=200), + ] + mock_process = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = None + mock_process.wait.return_value = 0 + mock_popen.return_value = mock_process + mock_getpgid.return_value = 12345 + + launcher = ServerLauncher() + launcher.start() + launcher.stop() + + # On Unix, should send signal to process group + mock_killpg.assert_called() + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + @patch("subprocess.Popen") + def test_stop_subprocess_force_kill( + self, + mock_popen: MagicMock, + mock_get: MagicMock, + ) -> None: + """Test force killing subprocess that doesn't stop gracefully.""" + mock_get.side_effect = [ + Exception("Not running"), + MagicMock(status_code=200), + ] + mock_process = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = None + mock_process.wait.side_effect = [ + subprocess.TimeoutExpired(cmd="test", timeout=10), + 0, # Second wait succeeds after force kill + ] + mock_popen.return_value = mock_process + + launcher = ServerLauncher() + launcher.start() + launcher.stop() + + mock_process.kill.assert_called() + + def test_stop_not_started(self) -> None: + """Test stop is no-op when not started.""" + launcher = ServerLauncher() + launcher.stop() # Should not raise + + assert launcher._started is False + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + def test_stop_container(self, mock_get: MagicMock) -> None: + """Test stopping container.""" + mock_get.side_effect = [ + Exception("Not running"), + MagicMock(status_code=200), + ] + + mock_docker = MagicMock() + mock_docker.errors.DockerException = Exception + mock_docker.errors.ImageNotFound = type("ImageNotFound", (Exception,), {}) + + mock_client = MagicMock() + mock_client.images.get.return_value = MagicMock() + + mock_container = MagicMock() + mock_container.id = "abc123def456" # pragma: allowlist secret + mock_container.status = "running" + mock_client.containers.run.return_value = mock_container + mock_client.containers.get.return_value = mock_container + + mock_docker.from_env.return_value = mock_client + + with patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker.errors}): + launcher = ServerLauncher(container=True) + launcher.start() + launcher.stop() + + assert launcher._started is False + assert launcher._container_id is None + mock_container.stop.assert_called_once_with(timeout=10) + + +class TestServerLauncherContextManager: + """Tests for context manager protocol.""" + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + @patch("subprocess.Popen") + def test_context_manager( + self, + mock_popen: MagicMock, + mock_get: MagicMock, + ) -> None: + """Test using launcher as context manager.""" + mock_get.side_effect = [ + Exception("Not running"), + MagicMock(status_code=200), + ] + mock_process = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = None + mock_process.wait.return_value = 0 + mock_popen.return_value = mock_process + + with ServerLauncher() as launcher: + assert launcher._started is True + + assert launcher._started is False + + @patch("taskweaver.ces.client.server_launcher.httpx.get") + @patch("subprocess.Popen") + def test_context_manager_with_exception( + self, + mock_popen: MagicMock, + mock_get: MagicMock, + ) -> None: + """Test context manager cleans up on exception.""" + mock_get.side_effect = [ + Exception("Not running"), + MagicMock(status_code=200), + ] + mock_process = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = None + mock_process.wait.return_value = 0 + mock_popen.return_value = mock_process + + with pytest.raises(ValueError): + with ServerLauncher() as launcher: + assert launcher._started is True + raise ValueError("Test error") + + assert launcher._started is False + + +class TestServerLauncherError: + """Tests for ServerLauncherError exception.""" + + def test_error_message(self) -> None: + """Test error with message.""" + error = ServerLauncherError("Something went wrong") + assert str(error) == "Something went wrong" diff --git a/tests/unit_tests/ces/test_server_models.py b/tests/unit_tests/ces/test_server_models.py new file mode 100644 index 000000000..c444e4613 --- /dev/null +++ b/tests/unit_tests/ces/test_server_models.py @@ -0,0 +1,428 @@ +"""Unit tests for the execution server Pydantic models.""" + +from datetime import datetime +from unittest.mock import MagicMock + +from taskweaver.ces.server.models import ( + ArtifactModel, + CreateSessionRequest, + CreateSessionResponse, + ErrorResponse, + ExecuteCodeRequest, + ExecuteCodeResponse, + ExecuteStreamResponse, + HealthResponse, + LoadPluginRequest, + LoadPluginResponse, + OutputEvent, + ResultEvent, + SessionInfoResponse, + StopSessionResponse, + UpdateVariablesRequest, + UpdateVariablesResponse, + artifact_from_execution, + execution_result_to_response, +) + + +class TestRequestModels: + """Tests for request models.""" + + def test_create_session_request_basic(self) -> None: + """Test CreateSessionRequest with required fields only.""" + req = CreateSessionRequest(session_id="test-session-123") + assert req.session_id == "test-session-123" + assert req.cwd is None + + def test_create_session_request_with_cwd(self) -> None: + """Test CreateSessionRequest with cwd specified.""" + req = CreateSessionRequest(session_id="test-session", cwd="/tmp/work") + assert req.session_id == "test-session" + assert req.cwd == "/tmp/work" + + def test_load_plugin_request(self) -> None: + """Test LoadPluginRequest model.""" + req = LoadPluginRequest( + name="test_plugin", + code="def test(): pass", + config={"key": "value"}, + ) + assert req.name == "test_plugin" + assert req.code == "def test(): pass" + assert req.config == {"key": "value"} + + def test_load_plugin_request_empty_config(self) -> None: + """Test LoadPluginRequest with default empty config.""" + req = LoadPluginRequest(name="plugin", code="pass") + assert req.config == {} + + def test_execute_code_request_basic(self) -> None: + """Test ExecuteCodeRequest with required fields.""" + req = ExecuteCodeRequest( + exec_id="exec-001", + code="print('hello')", + ) + assert req.exec_id == "exec-001" + assert req.code == "print('hello')" + assert req.stream is False + + def test_execute_code_request_streaming(self) -> None: + """Test ExecuteCodeRequest with streaming enabled.""" + req = ExecuteCodeRequest( + exec_id="exec-002", + code="print('hello')", + stream=True, + ) + assert req.stream is True + + def test_update_variables_request(self) -> None: + """Test UpdateVariablesRequest model.""" + req = UpdateVariablesRequest( + variables={"var1": "value1", "var2": "value2"}, + ) + assert req.variables == {"var1": "value1", "var2": "value2"} + + +class TestResponseModels: + """Tests for response models.""" + + def test_health_response(self) -> None: + """Test HealthResponse model.""" + resp = HealthResponse( + version="1.0.0", + active_sessions=5, + ) + assert resp.status == "healthy" + assert resp.version == "1.0.0" + assert resp.active_sessions == 5 + + def test_create_session_response(self) -> None: + """Test CreateSessionResponse model.""" + resp = CreateSessionResponse( + session_id="test-session", + cwd="/tmp/work", + ) + assert resp.session_id == "test-session" + assert resp.status == "created" + assert resp.cwd == "/tmp/work" + + def test_stop_session_response(self) -> None: + """Test StopSessionResponse model.""" + resp = StopSessionResponse(session_id="test-session") + assert resp.session_id == "test-session" + assert resp.status == "stopped" + + def test_session_info_response(self) -> None: + """Test SessionInfoResponse model.""" + now = datetime.utcnow() + resp = SessionInfoResponse( + session_id="test-session", + status="running", + created_at=now, + last_activity=now, + loaded_plugins=["plugin1", "plugin2"], + execution_count=10, + cwd="/tmp/work", + ) + assert resp.session_id == "test-session" + assert resp.status == "running" + assert resp.created_at == now + assert resp.loaded_plugins == ["plugin1", "plugin2"] + assert resp.execution_count == 10 + + def test_load_plugin_response(self) -> None: + """Test LoadPluginResponse model.""" + resp = LoadPluginResponse(name="test_plugin") + assert resp.name == "test_plugin" + assert resp.status == "loaded" + + def test_execute_code_response_success(self) -> None: + """Test ExecuteCodeResponse for successful execution.""" + resp = ExecuteCodeResponse( + execution_id="exec-001", + is_success=True, + output="Hello World", + stdout=["Hello World\n"], + ) + assert resp.execution_id == "exec-001" + assert resp.is_success is True + assert resp.error is None + assert resp.output == "Hello World" + assert resp.stdout == ["Hello World\n"] + + def test_execute_code_response_failure(self) -> None: + """Test ExecuteCodeResponse for failed execution.""" + resp = ExecuteCodeResponse( + execution_id="exec-002", + is_success=False, + error="SyntaxError: invalid syntax", + ) + assert resp.is_success is False + assert resp.error == "SyntaxError: invalid syntax" + + def test_execute_code_response_with_artifacts(self) -> None: + """Test ExecuteCodeResponse with artifacts.""" + artifact = ArtifactModel( + name="chart", + type="image", + mime_type="image/png", + file_name="chart.png", + preview="[chart image]", + ) + resp = ExecuteCodeResponse( + execution_id="exec-003", + is_success=True, + artifact=[artifact], + ) + assert len(resp.artifact) == 1 + assert resp.artifact[0].name == "chart" + assert resp.artifact[0].type == "image" + + def test_execute_stream_response(self) -> None: + """Test ExecuteStreamResponse model.""" + resp = ExecuteStreamResponse( + execution_id="exec-001", + stream_url="/api/v1/sessions/test/stream/exec-001", + ) + assert resp.execution_id == "exec-001" + assert resp.stream_url == "/api/v1/sessions/test/stream/exec-001" + + def test_update_variables_response(self) -> None: + """Test UpdateVariablesResponse model.""" + resp = UpdateVariablesResponse( + variables={"var1": "new_value"}, + ) + assert resp.status == "updated" + assert resp.variables == {"var1": "new_value"} + + def test_error_response(self) -> None: + """Test ErrorResponse model.""" + resp = ErrorResponse(detail="Session not found") + assert resp.detail == "Session not found" + + +class TestArtifactModel: + """Tests for ArtifactModel.""" + + def test_artifact_model_basic(self) -> None: + """Test ArtifactModel with basic fields.""" + artifact = ArtifactModel( + name="output", + type="file", + ) + assert artifact.name == "output" + assert artifact.type == "file" + assert artifact.mime_type == "" + assert artifact.file_content is None + assert artifact.download_url is None + + def test_artifact_model_with_content(self) -> None: + """Test ArtifactModel with inline content.""" + artifact = ArtifactModel( + name="result", + type="text", + file_content="Some text content", + file_content_encoding="str", + ) + assert artifact.file_content == "Some text content" + assert artifact.file_content_encoding == "str" + + def test_artifact_model_with_download_url(self) -> None: + """Test ArtifactModel with download URL.""" + artifact = ArtifactModel( + name="large_file", + type="file", + file_name="data.csv", + download_url="/api/v1/sessions/test/artifacts/data.csv", + ) + assert artifact.download_url == "/api/v1/sessions/test/artifacts/data.csv" + + +class TestSSEEventModels: + """Tests for SSE event models.""" + + def test_output_event_stdout(self) -> None: + """Test OutputEvent for stdout.""" + event = OutputEvent(type="stdout", text="Hello\n") + assert event.type == "stdout" + assert event.text == "Hello\n" + + def test_output_event_stderr(self) -> None: + """Test OutputEvent for stderr.""" + event = OutputEvent(type="stderr", text="Warning: something\n") + assert event.type == "stderr" + assert event.text == "Warning: something\n" + + def test_result_event(self) -> None: + """Test ResultEvent model.""" + event = ResultEvent( + execution_id="exec-001", + is_success=True, + output="result", + stdout=["line1\n", "line2\n"], + ) + assert event.execution_id == "exec-001" + assert event.is_success is True + assert event.output == "result" + assert len(event.stdout) == 2 + + +class TestUtilityFunctions: + """Tests for utility functions.""" + + def test_artifact_from_execution(self) -> None: + """Test artifact_from_execution conversion.""" + # Create a mock ExecutionArtifact + mock_artifact = MagicMock() + mock_artifact.name = "test_artifact" + mock_artifact.type = "image" + mock_artifact.mime_type = "image/png" + mock_artifact.original_name = "chart.png" + mock_artifact.file_name = "chart_001.png" + mock_artifact.file_content = None + mock_artifact.file_content_encoding = "str" + mock_artifact.preview = "[image preview]" + + result = artifact_from_execution(mock_artifact) + + assert isinstance(result, ArtifactModel) + assert result.name == "test_artifact" + assert result.type == "image" + assert result.mime_type == "image/png" + assert result.file_content is None + + def test_artifact_from_execution_with_content(self) -> None: + """Test artifact_from_execution with inline content.""" + mock_artifact = MagicMock() + mock_artifact.name = "small_file" + mock_artifact.type = "text" + mock_artifact.mime_type = "text/plain" + mock_artifact.original_name = "output.txt" + mock_artifact.file_name = "output.txt" + mock_artifact.file_content = "Hello World" + mock_artifact.file_content_encoding = "str" + mock_artifact.preview = "Hello World" + + result = artifact_from_execution(mock_artifact) + + assert result.file_content == "Hello World" + assert result.file_content_encoding == "str" + + def test_execution_result_to_response_success(self) -> None: + """Test execution_result_to_response for successful result.""" + # Create mock result + mock_result = MagicMock() + mock_result.execution_id = "exec-001" + mock_result.is_success = True + mock_result.error = None + mock_result.output = "42" + mock_result.stdout = ["output line\n"] + mock_result.stderr = [] + mock_result.log = [("INFO", "logger", "message")] + mock_result.artifact = [] + mock_result.variables = [("x", "42")] + + response = execution_result_to_response(mock_result, "session-001") + + assert isinstance(response, ExecuteCodeResponse) + assert response.execution_id == "exec-001" + assert response.is_success is True + assert response.output == "42" + assert response.stdout == ["output line\n"] + assert response.log == [("INFO", "logger", "message")] + assert response.variables == [("x", "42")] + + def test_execution_result_to_response_with_artifacts(self) -> None: + """Test execution_result_to_response with artifacts.""" + # Create mock artifact + mock_artifact = MagicMock() + mock_artifact.name = "chart" + mock_artifact.type = "image" + mock_artifact.mime_type = "image/png" + mock_artifact.original_name = "chart.png" + mock_artifact.file_name = "chart_001.png" + mock_artifact.file_content = None # Large file, no inline content + mock_artifact.file_content_encoding = "str" + mock_artifact.preview = "[chart]" + + mock_result = MagicMock() + mock_result.execution_id = "exec-002" + mock_result.is_success = True + mock_result.error = None + mock_result.output = "" + mock_result.stdout = [] + mock_result.stderr = [] + mock_result.log = [] + mock_result.artifact = [mock_artifact] + mock_result.variables = [] + + response = execution_result_to_response( + mock_result, + "session-001", + base_url="http://localhost:8000", + ) + + assert len(response.artifact) == 1 + assert response.artifact[0].name == "chart" + # Large artifact should have download URL + assert response.artifact[0].download_url == ( + "http://localhost:8000/api/v1/sessions/session-001/artifacts/chart_001.png" + ) + + def test_execution_result_to_response_failure(self) -> None: + """Test execution_result_to_response for failed result.""" + mock_result = MagicMock() + mock_result.execution_id = "exec-003" + mock_result.is_success = False + mock_result.error = "NameError: name 'undefined' is not defined" + mock_result.output = "" + mock_result.stdout = [] + mock_result.stderr = ["Traceback...\n", "NameError...\n"] + mock_result.log = [] + mock_result.artifact = [] + mock_result.variables = [] + + response = execution_result_to_response(mock_result, "session-001") + + assert response.is_success is False + assert "NameError" in response.error + assert len(response.stderr) == 2 + + +class TestModelSerialization: + """Tests for model serialization (JSON export).""" + + def test_request_model_json_export(self) -> None: + """Test that request models can be exported to JSON.""" + req = ExecuteCodeRequest( + exec_id="exec-001", + code="x = 42", + stream=True, + ) + json_data = req.model_dump() + assert json_data["exec_id"] == "exec-001" + assert json_data["code"] == "x = 42" + assert json_data["stream"] is True + + def test_response_model_json_export(self) -> None: + """Test that response models can be exported to JSON.""" + resp = ExecuteCodeResponse( + execution_id="exec-001", + is_success=True, + output="result", + artifact=[ + ArtifactModel(name="test", type="file"), + ], + ) + json_data = resp.model_dump() + assert json_data["execution_id"] == "exec-001" + assert json_data["is_success"] is True + assert len(json_data["artifact"]) == 1 + + def test_health_response_json_export(self) -> None: + """Test HealthResponse JSON serialization.""" + resp = HealthResponse(version="1.0.0", active_sessions=3) + json_data = resp.model_dump() + assert json_data["status"] == "healthy" + assert json_data["version"] == "1.0.0" + assert json_data["active_sessions"] == 3 diff --git a/tests/unit_tests/ces/test_session_manager.py b/tests/unit_tests/ces/test_session_manager.py new file mode 100644 index 000000000..758d9524a --- /dev/null +++ b/tests/unit_tests/ces/test_session_manager.py @@ -0,0 +1,521 @@ +"""Unit tests for ServerSessionManager.""" + +import os +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + +from taskweaver.ces.common import ExecutionResult +from taskweaver.ces.server.session_manager import ServerSession, ServerSessionManager + + +class TestServerSession: + """Tests for the ServerSession dataclass.""" + + def test_server_session_creation(self) -> None: + """Test ServerSession creation with defaults.""" + mock_env = MagicMock() + session = ServerSession( + session_id="test-session", + environment=mock_env, + ) + assert session.session_id == "test-session" + assert session.environment == mock_env + assert session.loaded_plugins == [] + assert session.execution_count == 0 + assert session.cwd == "" + assert session.session_dir == "" + + def test_server_session_with_custom_values(self) -> None: + """Test ServerSession with custom values.""" + mock_env = MagicMock() + now = datetime.utcnow() + session = ServerSession( + session_id="test-session", + environment=mock_env, + created_at=now, + last_activity=now, + loaded_plugins=["plugin1"], + execution_count=5, + cwd="/tmp/work", + session_dir="/tmp/sessions/test", + ) + assert session.loaded_plugins == ["plugin1"] + assert session.execution_count == 5 + assert session.cwd == "/tmp/work" + + def test_update_activity(self) -> None: + """Test that update_activity updates the timestamp.""" + mock_env = MagicMock() + session = ServerSession( + session_id="test-session", + environment=mock_env, + ) + old_activity = session.last_activity + + # Small delay to ensure different timestamp + import time + + time.sleep(0.01) + session.update_activity() + + assert session.last_activity >= old_activity + + +class TestServerSessionManager: + """Tests for ServerSessionManager.""" + + @pytest.fixture() + def manager(self, tmp_path: str) -> ServerSessionManager: + """Create a ServerSessionManager with a temp work dir.""" + return ServerSessionManager( + env_id="test-env", + work_dir=str(tmp_path), + ) + + def test_manager_initialization(self, tmp_path: str) -> None: + """Test manager initialization.""" + manager = ServerSessionManager( + env_id="test-env", + work_dir=str(tmp_path), + ) + assert manager.env_id == "test-env" + assert manager.work_dir == str(tmp_path) + assert manager.active_session_count == 0 + + def test_manager_default_env_id(self, tmp_path: str) -> None: + """Test manager uses default env_id from environment.""" + with patch.dict(os.environ, {"TASKWEAVER_ENV_ID": "custom-env"}, clear=False): + manager = ServerSessionManager(work_dir=str(tmp_path)) + assert manager.env_id == "custom-env" + + def test_active_session_count(self, manager: ServerSessionManager) -> None: + """Test active_session_count property.""" + assert manager.active_session_count == 0 + + def test_session_exists_false(self, manager: ServerSessionManager) -> None: + """Test session_exists returns False for non-existent session.""" + assert manager.session_exists("non-existent") is False + + def test_get_session_none(self, manager: ServerSessionManager) -> None: + """Test get_session returns None for non-existent session.""" + assert manager.get_session("non-existent") is None + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_create_session( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test creating a new session.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + session = manager.create_session("test-session") + + assert session.session_id == "test-session" + assert manager.session_exists("test-session") + assert manager.active_session_count == 1 + + # Verify Environment was created and started + mock_env_class.assert_called_once() + mock_env.start_session.assert_called_once() + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_create_session_with_cwd( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test creating a session with custom cwd.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + session = manager.create_session("test-session", cwd="/custom/path") + + assert session.cwd == "/custom/path" + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_create_duplicate_session_raises( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test that creating a duplicate session raises ValueError.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + manager.create_session("test-session") + + with pytest.raises(ValueError, match="already exists"): + manager.create_session("test-session") + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_stop_session( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test stopping a session.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + manager.create_session("test-session") + assert manager.session_exists("test-session") + + manager.stop_session("test-session") + + assert not manager.session_exists("test-session") + assert manager.active_session_count == 0 + mock_env.stop_session.assert_called_once_with("test-session") + + def test_stop_nonexistent_session_raises( + self, + manager: ServerSessionManager, + ) -> None: + """Test that stopping a non-existent session raises KeyError.""" + with pytest.raises(KeyError, match="not found"): + manager.stop_session("non-existent") + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_stop_session_with_env_error( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test that session is still removed even if env.stop_session fails.""" + mock_env = MagicMock() + mock_env.stop_session.side_effect = Exception("Cleanup error") + mock_env_class.return_value = mock_env + + manager.create_session("test-session") + manager.stop_session("test-session") + + # Session should be removed despite the error + assert not manager.session_exists("test-session") + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_load_plugin( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test loading a plugin into a session.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + manager.create_session("test-session") + manager.load_plugin( + session_id="test-session", + plugin_name="test_plugin", + plugin_code="def test(): pass", + plugin_config={"key": "value"}, + ) + + mock_env.load_plugin.assert_called_once_with( + session_id="test-session", + plugin_name="test_plugin", + plugin_impl="def test(): pass", + plugin_config={"key": "value"}, + ) + + session = manager.get_session("test-session") + assert session is not None + assert "test_plugin" in session.loaded_plugins + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_load_plugin_nonexistent_session( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test that loading plugin for non-existent session raises KeyError.""" + with pytest.raises(KeyError, match="not found"): + manager.load_plugin( + session_id="non-existent", + plugin_name="test_plugin", + plugin_code="def test(): pass", + ) + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_load_plugin_duplicate( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test that loading the same plugin twice doesn't duplicate in list.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + manager.create_session("test-session") + manager.load_plugin("test-session", "plugin1", "code") + manager.load_plugin("test-session", "plugin1", "code") + + session = manager.get_session("test-session") + assert session is not None + assert session.loaded_plugins.count("plugin1") == 1 + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_execute_code( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test executing code in a session.""" + mock_env = MagicMock() + mock_result = ExecutionResult( + execution_id="exec-001", + code="print('hello')", + is_success=True, + output="", + ) + mock_env.execute_code.return_value = mock_result + mock_env_class.return_value = mock_env + + manager.create_session("test-session") + result = manager.execute_code( + session_id="test-session", + exec_id="exec-001", + code="print('hello')", + ) + + assert result.is_success is True + mock_env.execute_code.assert_called_once_with( + session_id="test-session", + code="print('hello')", + exec_id="exec-001", + on_output=None, + ) + + session = manager.get_session("test-session") + assert session is not None + assert session.execution_count == 1 + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_execute_code_with_callback( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test executing code with output callback.""" + mock_env = MagicMock() + mock_result = ExecutionResult( + execution_id="exec-001", + code="print('hello')", + is_success=True, + ) + mock_env.execute_code.return_value = mock_result + mock_env_class.return_value = mock_env + + def on_output(stream: str, text: str) -> None: + pass + + manager.create_session("test-session") + manager.execute_code( + session_id="test-session", + exec_id="exec-001", + code="print('hello')", + on_output=on_output, + ) + + # Verify callback was passed + call_kwargs = mock_env.execute_code.call_args[1] + assert call_kwargs["on_output"] == on_output + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_execute_code_nonexistent_session( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test that executing code for non-existent session raises KeyError.""" + with pytest.raises(KeyError, match="not found"): + manager.execute_code( + session_id="non-existent", + exec_id="exec-001", + code="print('hello')", + ) + + @patch("taskweaver.ces.server.session_manager.Environment") + @pytest.mark.asyncio + async def test_execute_code_async( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test async code execution.""" + mock_env = MagicMock() + mock_result = ExecutionResult( + execution_id="exec-001", + code="x = 42", + is_success=True, + output="42", + ) + mock_env.execute_code.return_value = mock_result + mock_env_class.return_value = mock_env + + manager.create_session("test-session") + result = await manager.execute_code_async( + session_id="test-session", + exec_id="exec-001", + code="x = 42", + ) + + assert result.is_success is True + assert result.output == "42" + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_update_session_variables( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test updating session variables.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + manager.create_session("test-session") + manager.update_session_variables( + session_id="test-session", + variables={"var1": "value1"}, + ) + + mock_env.update_session_var.assert_called_once_with( + "test-session", + {"var1": "value1"}, + ) + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_update_session_variables_nonexistent( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test that updating vars for non-existent session raises KeyError.""" + with pytest.raises(KeyError, match="not found"): + manager.update_session_variables("non-existent", {"var": "val"}) + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_get_artifact_path_exists( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + tmp_path: str, + ) -> None: + """Test getting artifact path when file exists.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + session = manager.create_session("test-session") + + # Create a test artifact file + artifact_path = os.path.join(session.cwd, "test_artifact.png") + with open(artifact_path, "w") as f: + f.write("test") + + result = manager.get_artifact_path("test-session", "test_artifact.png") + assert result == artifact_path + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_get_artifact_path_not_exists( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test getting artifact path when file doesn't exist.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + manager.create_session("test-session") + result = manager.get_artifact_path("test-session", "nonexistent.png") + assert result is None + + def test_get_artifact_path_nonexistent_session( + self, + manager: ServerSessionManager, + ) -> None: + """Test getting artifact path for non-existent session.""" + result = manager.get_artifact_path("non-existent", "file.png") + assert result is None + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_cleanup_all( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test cleaning up all sessions.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + manager.create_session("session-1") + manager.create_session("session-2") + manager.create_session("session-3") + assert manager.active_session_count == 3 + + manager.cleanup_all() + + assert manager.active_session_count == 0 + assert mock_env.stop_session.call_count == 3 + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_cleanup_all_with_errors( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test cleanup_all continues despite individual session errors.""" + mock_env = MagicMock() + mock_env.stop_session.side_effect = [Exception("Error 1"), None, Exception("Error 2")] + mock_env_class.return_value = mock_env + + manager.create_session("session-1") + manager.create_session("session-2") + manager.create_session("session-3") + + # Should not raise, just log errors + manager.cleanup_all() + assert manager.active_session_count == 0 + + +class TestServerSessionManagerThreadSafety: + """Tests for thread safety of ServerSessionManager.""" + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_concurrent_session_creation( + self, + mock_env_class: MagicMock, + tmp_path: str, + ) -> None: + """Test that concurrent session creation is thread-safe.""" + import threading + + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + manager = ServerSessionManager(work_dir=str(tmp_path)) + created_sessions: list[str] = [] + errors: list[Exception] = [] + lock = threading.Lock() + + def create_session(session_id: str) -> None: + try: + manager.create_session(session_id) + with lock: + created_sessions.append(session_id) + except Exception as e: + with lock: + errors.append(e) + + threads = [threading.Thread(target=create_session, args=(f"session-{i}",)) for i in range(10)] + + for t in threads: + t.start() + for t in threads: + t.join() + + # All sessions should be created successfully + assert len(created_sessions) == 10 + assert len(errors) == 0 + assert manager.active_session_count == 10 diff --git a/website/docs/FAQ.md b/website/docs/FAQ.md index 8ca3a5a16..874f3bdde 100644 --- a/website/docs/FAQ.md +++ b/website/docs/FAQ.md @@ -18,10 +18,9 @@ However, you should check if your use case needs the planning step. ### Q: Why TaskWeaver fails and the logs say "Failed to connect to docker.daemon"? -A: This error typically happens when TaskWeaver is running in the `container` mode and cannot connect to the Docker daemon. -We have switched to the `container` mode by default to provide a more secure environment for code execution. -To opt out of the `container` mode, you can set the `execution_service.kernel_mode` parameter to `local` in the `taskweaver_config.json` file. -However, you should be aware that TaskWeaver can interact with the host machine directly in the `local` mode, which may have security risks. +A: This error typically happens when TaskWeaver is configured to run the execution server in container mode and cannot connect to the Docker daemon. +To run without Docker, ensure `execution_service.server.container` is set to `false` (or not set, as local mode is the default) in the `taskweaver_config.json` file. +However, you should be aware that in local mode TaskWeaver can interact with the host machine directly, which may have security risks. ### Q: Why I see errors saying the Planner failed to generate the `send_to`, `message` or other fields? diff --git a/website/docs/code_execution.md b/website/docs/code_execution.md index d19d8c9a8..b8b4122e5 100644 --- a/website/docs/code_execution.md +++ b/website/docs/code_execution.md @@ -1,43 +1,47 @@ # Code Execution ->💡We have set the `container` mode as default for code execution, especially when the usage of the agent -is open to untrusted users. Refer to [Docker Security](https://docs.docker.com/engine/security/) for better understanding -of the security features of Docker. To opt for the `local` mode, you need to explicitly set the `execution_service.kernel_mode` -parameter in the `taskweaver_config.json` file to `local`. +>💡TaskWeaver uses a **server-based architecture** for code execution. By default, the server auto-starts locally. +>For isolated environments, you can run the server in a Docker container by setting `execution_service.server.container` to `true`. +>Refer to [Docker Security](https://docs.docker.com/engine/security/) for better understanding of the security features of Docker. TaskWeaver is a code-first agent framework, which means that it always converts the user request into code and executes the code to generate the response. In our current implementation, we use a Jupyter Kernel to execute the code. We choose Jupyter Kernel because it is a well-established tool for interactive computing, and it supports many programming languages. -## Two Modes of Code Execution +## Execution Server Architecture -TaskWeaver supports two modes of code execution: `local` and `container`. -The `container` mode is the default mode. The key difference between the two modes is that the `container` mode -executes the code inside a Docker container, which provides a more secure environment for code execution, while -the `local` mode executes the code as a subprocess of the TaskWeaver process. -As a result, in the `local` mode, if the user has malicious intent, the user could potentially -instruct TaskWeaver to execute harmful code on the host machine. In addition, the LLM could also generate -harmful code, leading to potential security risks. +TaskWeaver uses an HTTP-based execution server that wraps the Jupyter kernel: + +- **Local mode (default)**: Server auto-starts as a subprocess with full filesystem access +- **Container mode**: Server runs in Docker for security isolation +- **Remote mode**: Connect to a pre-deployed server for GPU access or shared resources + +The key difference between local and container modes is that container mode executes code inside a Docker container, +which provides a more secure environment. In local mode, if the user has malicious intent, they could potentially +instruct TaskWeaver to execute harmful code on the host machine. :::danger -Please be cautious when using the `local` mode, especially when the usage of the agent is open to untrusted users. +Please be cautious when using local mode, especially when the usage of the agent is open to untrusted users. ::: ## How to Configure the Code Execution Mode -To configure the code execution mode, you need to set the `execution_service.kernel_mode` parameter in the -`taskweaver_config.json` file. The value of the parameter could be `local` or `container`. The default value -is `container`. +To run the execution server in a Docker container, set the `execution_service.server.container` parameter in the +`taskweaver_config.json` file to `true`. By default, the server runs locally as a subprocess. + +```json +{ + "execution_service.server.container": true +} +``` -TaskWeaver supports the `local` mode without any additional setup. However, to use the `container` mode, -there are a few prerequisites: +To use container mode, there are a few prerequisites: - Docker is installed on the host machine. - A Docker image is built and available on the host machine for code execution. -- The `execution_service.kernel_mode` parameter is set to `container` in the `taskweaver_config.json` file. Once the code repository is cloned to your local machine, you can build the Docker image by running the following command in the root directory of the code repository: @@ -85,9 +89,9 @@ cd TaskWeaver/scripts If you have successfully rebuilt the Docker image, you can check the new image by running `docker images`. After building the Docker image, you need to restart the TaskWeaver agent to use the new Docker image. -## Limitations of the `container` Mode +## Limitations of Container Mode -The `container` mode is more secure than the `local` mode, but it also has some limitations: +Container mode is more secure than local mode, but it also has some limitations: - The startup time of the `container` mode is longer than the `local` mode, because it needs to start a Docker container. - As the Jupyter Kernel is running inside a Docker container, it has limited access to the host machine. We are mapping the diff --git a/website/docs/configurations/overview.md b/website/docs/configurations/overview.md index aab9d1115..085d4f3c7 100644 --- a/website/docs/configurations/overview.md +++ b/website/docs/configurations/overview.md @@ -38,7 +38,10 @@ The following table lists the parameters in the configuration file: | `session.roles` | The roles included for the conversation. | ["planner", "code_interpreter"] | | `round_compressor.rounds_to_compress` | The number of rounds to compress. | `2` | | `round_compressor.rounds_to_retain` | The number of rounds to retain. | `3` | -| `execution_service.kernel_mode` | The mode of the code executor, could be `local` or `container`. | `container` | +| `execution_service.server.url` | The URL of the execution server. | `http://localhost:8000` | +| `execution_service.server.auto_start` | Whether to auto-start the server if not running. | `true` | +| `execution_service.server.container` | Whether to run the execution server in a Docker container. | `false` | +| `execution_service.server.timeout` | Request timeout in seconds for server communication. | `300` | :::tip $\{AppBaseDir\} is the project directory. From 5de150086efc23dfe68b6c4f883194304ad26a72 Mon Sep 17 00:00:00 2001 From: liqun Date: Wed, 28 Jan 2026 14:34:59 +0800 Subject: [PATCH 10/10] fix UTs --- pytest.ini | 4 + taskweaver/ces/AGENTS.md | 140 +++++++++ taskweaver/ces/client/execution_client.py | 31 ++ taskweaver/ces/common.py | 8 + taskweaver/ces/manager/defer.py | 7 + taskweaver/ces/manager/execution_service.py | 10 + taskweaver/ces/manager/sub_proc.py | 19 ++ taskweaver/ces/runtime/context.py | 14 +- taskweaver/ces/server/models.py | 16 + taskweaver/ces/server/routes.py | 35 +++ taskweaver/ces/server/session_manager.py | 34 ++ taskweaver/chat/console/chat.py | 4 +- taskweaver/session/session.py | 72 ++++- tests/unit_tests/ces/conftest.py | 6 + tests/unit_tests/ces/diagnose_session.py | 114 +++++++ tests/unit_tests/ces/diagnose_simple.py | 38 +++ tests/unit_tests/ces/test_context.py | 291 ++++++++++++++++++ tests/unit_tests/ces/test_execution_client.py | 90 ++++++ .../unit_tests/ces/test_execution_service.py | 35 ++- tests/unit_tests/ces/test_server_launcher.py | 27 +- tests/unit_tests/ces/test_session.py | 2 + tests/unit_tests/ces/test_session_manager.py | 158 +++++++++- .../data/prompts/generator_plugin_only.yaml | 1 + .../data/prompts/generator_prompt.yaml | 10 +- .../data/prompts/planner_prompt.yaml | 61 +++- tests/unit_tests/test_planner.py | 8 +- 26 files changed, 1190 insertions(+), 45 deletions(-) create mode 100644 tests/unit_tests/ces/diagnose_session.py create mode 100644 tests/unit_tests/ces/diagnose_simple.py create mode 100644 tests/unit_tests/ces/test_context.py diff --git a/pytest.ini b/pytest.ini index 7ccf85a06..d423b89fe 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,9 @@ [pytest] +pythonpath = . +asyncio_mode = auto markers = app_config: mark a test that requires the app config + integration: mark a test as an integration test (may start real services) + asyncio: mark a test as async testpaths = tests \ No newline at end of file diff --git a/taskweaver/ces/AGENTS.md b/taskweaver/ces/AGENTS.md index 889a34813..724acdc71 100644 --- a/taskweaver/ces/AGENTS.md +++ b/taskweaver/ces/AGENTS.md @@ -171,6 +171,7 @@ Connect to pre-started server. API key required. | POST | `/api/v1/sessions/{id}/execute` | Execute code | | GET | `/api/v1/sessions/{id}/stream/{exec_id}` | SSE stream | | POST | `/api/v1/sessions/{id}/variables` | Update variables | +| POST | `/api/v1/sessions/{id}/files` | Upload file to session cwd | | GET | `/api/v1/sessions/{id}/artifacts/{file}` | Download artifact | ## Usage @@ -236,6 +237,140 @@ with ExecutionClient( 4. **Streaming**: SSE events for stdout/stderr during execution 5. **Session Cleanup**: `DELETE /sessions/{id}` → Environment.stop_session() +## File Upload Flow + +File upload enables the `/load` CLI command to transfer files from the client machine to the execution server's working directory. This is essential when the execution server runs in a container or on a remote machine where the client's local filesystem is not accessible. + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ TASKWEAVER CLIENT │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌───────────────────────────────┐ │ +│ │ Session │───▶│ _upload_file│───▶│ ExecutionServiceClient │ │ +│ │ /load cmd │ │ (lazy) │ │ .upload_file() │ │ +│ └─────────────┘ └─────────────┘ └───────────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────┐ │ +│ │ ExecutionClient.upload_file() │ │ +│ │ - Read file content │ │ +│ │ - Base64 encode │ │ +│ │ - HTTP POST │ │ +│ └───────────────┬───────────────┘ │ +└────────────────────────────────────────────────────────┼────────────────────┘ + │ + │ POST /api/v1/sessions/{id}/files + │ {filename, content (base64), encoding} + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ EXECUTION SERVER │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ routes.upload_file() │ │ +│ │ - Validate session exists │ │ +│ │ - Base64 decode content │ │ +│ │ - Call session_manager.upload_file() │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ ServerSessionManager.upload_file() │ │ +│ │ - Sanitize filename (prevent path traversal) │ │ +│ │ - Write to {session.cwd}/{filename} │ │ +│ │ - Return full path │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ Session Working Directory │ │ +│ │ /workspace/{session_id}/cwd/ │ │ +│ │ └── uploaded_file.csv │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Request/Response Models + +```python +# Request (server/models.py) +class UploadFileRequest(BaseModel): + filename: str # Target filename (basename extracted, path traversal prevented) + content: str # File content (base64 encoded for binary) + encoding: Literal["base64", "text"] = "base64" + +# Response (server/models.py) +class UploadFileResponse(BaseModel): + filename: str # Uploaded filename + status: Literal["uploaded"] = "uploaded" + path: str # Full path where file was saved on server +``` + +### Client Usage + +```python +from taskweaver.ces.client import ExecutionClient + +with ExecutionClient(session_id="my-session", server_url="http://localhost:8000") as client: + client.start() + + # Upload a file + with open("/local/path/data.csv", "rb") as f: + content = f.read() + saved_path = client.upload_file("data.csv", content) + + # Now the file is available in the session's cwd + result = client.execute_code("exec-1", "import pandas as pd; df = pd.read_csv('data.csv')") +``` + +### Session Integration + +The `Session` class uses a lazily-initialized upload client: + +```python +# In taskweaver/session/session.py +class Session: + def _get_upload_client(self): + """Lazy client creation - only created when first upload occurs.""" + if not hasattr(self, "_upload_client"): + self._upload_client = self.exec_mgr.get_session_client( + self.session_id, + session_dir=self.workspace, + cwd=self.execution_cwd, + ) + self._upload_client_started = False + + if not self._upload_client_started: + self._upload_client.start() + self._upload_client_started = True + + return self._upload_client + + def _upload_file(self, name: str, path: str = None, content: bytes = None) -> str: + """Upload file to execution server.""" + target_name = os.path.basename(name) + + if path is not None: + with open(path, "rb") as f: + file_content = f.read() + elif content is not None: + file_content = content + else: + raise ValueError("path or content must be provided") + + client = self._get_upload_client() + client.upload_file(target_name, file_content) + return target_name +``` + +### Security Considerations + +1. **Path Traversal Prevention**: Server sanitizes filename using `os.path.basename()` to prevent `../../etc/passwd` attacks +2. **Session Isolation**: Files are written only to the session's own cwd directory +3. **API Key Authentication**: Upload endpoint respects the same API key auth as other endpoints +4. **Size Limits**: Large files should be chunked or streamed (not yet implemented) + ## Custom Kernel Magics (kernel/ext.py) ```python @@ -271,6 +406,11 @@ Unit tests in `tests/unit_tests/ces/`: | `test_server_launcher.py` | ServerLauncher (mocked subprocess/docker) | | `test_execution_service.py` | ExecutionServiceProvider | +**TODO**: Add tests for file upload functionality: +- `ExecutionClient.upload_file()` - mock HTTP POST, verify base64 encoding +- `ServerSessionManager.upload_file()` - verify file written, path traversal blocked +- `routes.upload_file()` - integration test with mocked session manager + Run tests: ```bash pytest tests/unit_tests/ces/ -v diff --git a/taskweaver/ces/client/execution_client.py b/taskweaver/ces/client/execution_client.py index 0fcdb2774..927d0a376 100644 --- a/taskweaver/ces/client/execution_client.py +++ b/taskweaver/ces/client/execution_client.py @@ -431,6 +431,37 @@ def download_artifact(self, filename: str) -> bytes: ) return response.content + def upload_file( + self, + filename: str, + content: bytes, + ) -> str: + """Upload a file to the session's working directory. + + Args: + filename: Target filename. + content: File content as bytes. + + Returns: + Path where the file was saved on the server. + + Raises: + ExecutionClientError: If upload fails. + """ + import base64 + + response = self._client.post( + f"/api/v1/sessions/{self.session_id}/files", + json={ + "filename": filename, + "content": base64.b64encode(content).decode("ascii"), + "encoding": "base64", + }, + ) + result = self._handle_response(response) + logger.info(f"Uploaded file {filename} to session {self.session_id}") + return result.get("path", "") + def close(self) -> None: """Close the HTTP client and release resources.""" self._client.close() diff --git a/taskweaver/ces/common.py b/taskweaver/ces/common.py index d2e8685c0..aad188b06 100644 --- a/taskweaver/ces/common.py +++ b/taskweaver/ces/common.py @@ -111,6 +111,14 @@ def execute_code( ) -> ExecutionResult: ... + @abstractmethod + def upload_file( + self, + filename: str, + content: bytes, + ) -> str: + ... + KernelModeType = Literal["local", "container"] diff --git a/taskweaver/ces/manager/defer.py b/taskweaver/ces/manager/defer.py index 7b8cecd07..43df5edb6 100644 --- a/taskweaver/ces/manager/defer.py +++ b/taskweaver/ces/manager/defer.py @@ -91,6 +91,13 @@ def execute_code( ) -> ExecutionResult: return self._get_proxy_client().execute_code(exec_id, code, on_output=on_output) + def upload_file( + self, + filename: str, + content: bytes, + ) -> str: + return self._get_proxy_client().upload_file(filename, content) + def _get_proxy_client(self) -> Client: return self._init_deferred_var()() diff --git a/taskweaver/ces/manager/execution_service.py b/taskweaver/ces/manager/execution_service.py index 31f471e90..42d50c32c 100644 --- a/taskweaver/ces/manager/execution_service.py +++ b/taskweaver/ces/manager/execution_service.py @@ -107,6 +107,16 @@ def execute_code( raise RuntimeError("Client not started") return self._client.execute_code(exec_id, code, on_output=on_output) + def upload_file( + self, + filename: str, + content: bytes, + ) -> str: + """Upload a file to the session's working directory.""" + if self._client is None: + raise RuntimeError("Client not started") + return self._client.upload_file(filename, content) + class ExecutionServiceProvider(Manager): """Manager implementation that uses the HTTP execution server. diff --git a/taskweaver/ces/manager/sub_proc.py b/taskweaver/ces/manager/sub_proc.py index 27ea9508b..65d4e2491 100644 --- a/taskweaver/ces/manager/sub_proc.py +++ b/taskweaver/ces/manager/sub_proc.py @@ -60,6 +60,25 @@ def execute_code( on_output=on_output, ) + def upload_file( + self, + filename: str, + content: bytes, + ) -> str: + """Upload a file to the session's working directory. + + For subprocess mode, this writes directly to the local filesystem + since the subprocess shares the same filesystem as the caller. + """ + # Sanitize filename to prevent path traversal + safe_filename = os.path.basename(filename) + file_path = os.path.join(self.cwd, safe_filename) + + with open(file_path, "wb") as f: + f.write(content) + + return file_path + class SubProcessManager(Manager): def __init__( diff --git a/taskweaver/ces/runtime/context.py b/taskweaver/ces/runtime/context.py index 1c4b38818..2d15c1fa1 100644 --- a/taskweaver/ces/runtime/context.py +++ b/taskweaver/ces/runtime/context.py @@ -157,15 +157,22 @@ def get_session_var( def extract_visible_variables(self, local_ns: Dict[str, Any]) -> List[Tuple[str, str]]: ignore_names = { + # IPython/Jupyter internals "__builtins__", "In", "Out", "get_ipython", "exit", "quit", + # Common library aliases "pd", "np", "plt", + # REPL/kernel internals + "original_ps1", + "is_wsl", + "PS1", + "REPLHooks", } visible: List[Tuple[str, str]] = [] @@ -194,7 +201,12 @@ def extract_visible_variables(self, local_ns: Dict[str, Any]) -> List[Tuple[str, continue try: - rendered = repr(value) + # Use str() for strings to avoid extra quotes from repr() + # e.g., repr("hello") returns "'hello'" but str("hello") returns "hello" + if isinstance(value, str): + rendered = value + else: + rendered = repr(value) except Exception: rendered = "" diff --git a/taskweaver/ces/server/models.py b/taskweaver/ces/server/models.py index 817018c7d..4dcee6320 100644 --- a/taskweaver/ces/server/models.py +++ b/taskweaver/ces/server/models.py @@ -39,6 +39,14 @@ class UpdateVariablesRequest(BaseModel): variables: Dict[str, str] = Field(..., description="Session variables to update") +class UploadFileRequest(BaseModel): + """Request to upload a file to a session's working directory.""" + + filename: str = Field(..., description="Target filename in the session's cwd") + content: str = Field(..., description="File content (base64 encoded for binary, plain for text)") + encoding: Literal["base64", "text"] = Field("base64", description="Content encoding") + + # ============================================================================= # Response Models # ============================================================================= @@ -128,6 +136,14 @@ class UpdateVariablesResponse(BaseModel): variables: Dict[str, str] = Field(..., description="Updated variables") +class UploadFileResponse(BaseModel): + """Response after uploading a file.""" + + filename: str = Field(..., description="Uploaded filename") + status: Literal["uploaded"] = "uploaded" + path: str = Field(..., description="Path where file was saved") + + class ErrorResponse(BaseModel): """Standard error response.""" diff --git a/taskweaver/ces/server/routes.py b/taskweaver/ces/server/routes.py index 7a5963be5..277ed8288 100644 --- a/taskweaver/ces/server/routes.py +++ b/taskweaver/ces/server/routes.py @@ -26,6 +26,8 @@ StopSessionResponse, UpdateVariablesRequest, UpdateVariablesResponse, + UploadFileRequest, + UploadFileResponse, execution_result_to_response, ) from taskweaver.ces.server.session_manager import ServerSessionManager @@ -439,6 +441,39 @@ async def update_variables( raise HTTPException(status_code=500, detail=str(e)) +@router.post( + "/sessions/{session_id}/files", + response_model=UploadFileResponse, + dependencies=[Depends(verify_api_key)], +) +async def upload_file( + session_id: str, + request: UploadFileRequest, + session_manager: ServerSessionManager = Depends(get_session_manager), +) -> UploadFileResponse: + """Upload a file to a session's working directory.""" + if not session_manager.session_exists(session_id): + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + try: + import base64 + + if request.encoding == "base64": + content = base64.b64decode(request.content) + else: + content = request.content.encode("utf-8") + + file_path = session_manager.upload_file(session_id, request.filename, content) + return UploadFileResponse( + filename=request.filename, + status="uploaded", + path=file_path, + ) + except Exception as e: + logger.error(f"Failed to upload file {request.filename} to session {session_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + # ============================================================================= # Artifacts # ============================================================================= diff --git a/taskweaver/ces/server/session_manager.py b/taskweaver/ces/server/session_manager.py index 2832eb3a3..d8643972d 100644 --- a/taskweaver/ces/server/session_manager.py +++ b/taskweaver/ces/server/session_manager.py @@ -366,6 +366,40 @@ def get_artifact_path(self, session_id: str, filename: str) -> Optional[str]: return None + def upload_file( + self, + session_id: str, + filename: str, + content: bytes, + ) -> str: + """Upload a file to a session's working directory. + + Args: + session_id: Session identifier. + filename: Target filename. + content: File content as bytes. + + Returns: + Full path where the file was saved. + + Raises: + KeyError: If session does not exist. + """ + session = self.get_session(session_id) + if session is None: + raise KeyError(f"Session {session_id} not found") + + # Sanitize filename to prevent path traversal + safe_filename = os.path.basename(filename) + file_path = os.path.join(session.cwd, safe_filename) + + with open(file_path, "wb") as f: + f.write(content) + + session.update_activity() + logger.info(f"Uploaded file {safe_filename} to session {session_id}") + return file_path + def cleanup_all(self) -> None: """Stop all sessions and clean up resources.""" with self._lock: diff --git a/taskweaver/chat/console/chat.py b/taskweaver/chat/console/chat.py index 3f5f7656d..923eeef71 100644 --- a/taskweaver/chat/console/chat.py +++ b/taskweaver/chat/console/chat.py @@ -606,7 +606,9 @@ def _save_memory(self): def _load_file(self, file_to_load: str): import os - file_path = os.path.realpath(file_to_load.strip()) + # Strip whitespace and surrounding quotes (single or double) + file_to_load = file_to_load.strip().strip('"').strip("'") + file_path = os.path.realpath(file_to_load) file_name = os.path.basename(file_path) if not os.path.exists(file_path): error_message(f"File '{file_to_load}' not found") diff --git a/taskweaver/session/session.py b/taskweaver/session/session.py index 9bd00a57e..e33b85317 100644 --- a/taskweaver/session/session.py +++ b/taskweaver/session/session.py @@ -1,10 +1,10 @@ import os -import shutil from dataclasses import dataclass from typing import Any, Dict, List, Literal, Optional from injector import Injector, inject +from taskweaver.ces.common import Manager from taskweaver.config.module_config import ModuleConfig from taskweaver.logging import TelemetryLogger from taskweaver.memory import Memory, Post, Round @@ -50,8 +50,9 @@ def __init__( app_injector: Injector, logger: TelemetryLogger, tracing: Tracing, - config: AppSessionConfig, # TODO: change to SessionConfig + config: AppSessionConfig, role_registry: RoleRegistry, + exec_mgr: Manager, ) -> None: """ Initialize the session. @@ -62,12 +63,14 @@ def __init__( :param tracing: The tracing. :param config: The configuration. :param role_registry: The role registry. + :param exec_mgr: The execution manager for file uploads. """ assert session_id is not None, "session_id must be provided" self.logger = logger self.tracing = tracing self.session_injector = app_injector.create_child_injector() self.config = config + self.exec_mgr = exec_mgr self.session_id: str = session_id @@ -315,21 +318,59 @@ def send_message( return chat_round + def _get_upload_client(self): + """Get or create the upload client for file uploads. + + Lazily creates and starts the client on first use. + """ + if not hasattr(self, "_upload_client"): + self._upload_client = self.exec_mgr.get_session_client( + self.session_id, + session_dir=self.workspace, + cwd=self.execution_cwd, + ) + self._upload_client_started = False + + if not self._upload_client_started: + self._upload_client.start() + self._upload_client_started = True + + return self._upload_client + @tracing_decorator def _upload_file(self, name: str, path: Optional[str] = None, content: Optional[bytes] = None) -> str: + """Upload a file to the execution server's working directory. + + Args: + name: The filename (may include path, only basename is used). + path: Path to a local file to upload. + content: Raw bytes to upload. + + Returns: + The filename on the server. + + Raises: + ValueError: If neither path nor content is provided. + """ target_name = name.split("/")[-1] - target_path = self._get_full_path(self.execution_cwd, target_name) - self.tracing.set_span_attribute("target_path", target_path) + self.tracing.set_span_attribute("target_name", target_name) + + # Get file content if path is not None: - shutil.copyfile(path, target_path) - return target_name - if content is not None: - with open(target_path, "wb") as f: - f.write(content) - return target_name + with open(path, "rb") as f: + file_content = f.read() + elif content is not None: + file_content = content + else: + self.tracing.set_span_status("ERROR", "path or file_content must be provided") + raise ValueError("path or file_content must be provided") + + # Upload via HTTP client + client = self._get_upload_client() + result = client.upload_file(target_name, file_content) + self.tracing.set_span_attribute("upload_result", result) - self.tracing.set_span_status("ERROR", "path or file_content must be provided") - raise ValueError("path or file_content") + return target_name def _get_full_path( self, @@ -355,6 +396,13 @@ def stop(self) -> None: for worker in self.worker_instances.values(): worker.close() + # Clean up upload client if it was created + if hasattr(self, "_upload_client") and self._upload_client_started: + try: + self._upload_client.stop() + except Exception: + pass # Ignore errors during cleanup + def to_dict(self) -> Dict[str, str]: return { "session_id": self.session_id, diff --git a/tests/unit_tests/ces/conftest.py b/tests/unit_tests/ces/conftest.py index 55c7ccba7..d4656113a 100644 --- a/tests/unit_tests/ces/conftest.py +++ b/tests/unit_tests/ces/conftest.py @@ -3,6 +3,12 @@ @pytest.fixture() def ces_manager(tmp_path: str): + """Create a real CES manager for integration tests. + + Note: This fixture creates actual Jupyter kernels and should only be used + in integration tests, not unit tests. Tests using this fixture may hang + if the execution environment is not properly configured. + """ from taskweaver.ces import code_execution_service_factory return code_execution_service_factory(tmp_path) diff --git a/tests/unit_tests/ces/diagnose_session.py b/tests/unit_tests/ces/diagnose_session.py new file mode 100644 index 000000000..d5f1028c7 --- /dev/null +++ b/tests/unit_tests/ces/diagnose_session.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python +"""Diagnostic script to identify where test_session.py hangs. + +Run with: conda run -n taskweaver python tests/unit_tests/ces/diagnose_session.py +""" + +import sys +import time + + +def timestamp(): + return f"[{time.strftime('%H:%M:%S')}]" + + +def log(msg: str): + print(f"{timestamp()} {msg}", flush=True) + + +def main(): + log("=== Starting CES Session Diagnostics ===") + + # Step 1: Import test + log("Step 1: Testing imports...") + start = time.time() + try: + from taskweaver.ces import code_execution_service_factory + + log(f" Imports OK ({time.time() - start:.2f}s)") + except Exception as e: + log(f" FAILED: {e}") + return 1 + + # Step 2: Create manager (should be fast - deferred) + log("Step 2: Creating manager (deferred)...") + start = time.time() + try: + import tempfile + + tmp_dir = tempfile.mkdtemp(prefix="ces_diag_") + manager = code_execution_service_factory(tmp_dir) + log(f" Manager created ({time.time() - start:.2f}s)") + log(f" Type: {type(manager).__name__}") + except Exception as e: + log(f" FAILED: {e}") + return 1 + + # Step 3: Get session client (should be fast - deferred) + log("Step 3: Getting session client (deferred)...") + start = time.time() + try: + session = manager.get_session_client("diag-session") + log(f" Session client created ({time.time() - start:.2f}s)") + log(f" Type: {type(session).__name__}") + except Exception as e: + log(f" FAILED: {e}") + return 1 + + # Step 4: Start session (THIS triggers actual initialization) + log("Step 4: Starting session (triggers server + kernel init)...") + log(" This is where the hang likely occurs...") + start = time.time() + try: + session.start() + log(f" Session started ({time.time() - start:.2f}s)") + except Exception as e: + log(f" FAILED after {time.time() - start:.2f}s: {e}") + import traceback + + traceback.print_exc() + return 1 + + # Step 5: Execute simple code + log("Step 5: Executing simple code...") + start = time.time() + try: + result = session.execute_code("test-1", "'hello'") + log(f" Execution completed ({time.time() - start:.2f}s)") + log(f" Success: {result.is_success}") + log(f" Output: {result.output}") + if result.error: + log(f" Error: {result.error}") + except Exception as e: + log(f" FAILED after {time.time() - start:.2f}s: {e}") + import traceback + + traceback.print_exc() + return 1 + + # Step 6: Stop session + log("Step 6: Stopping session...") + start = time.time() + try: + session.stop() + log(f" Session stopped ({time.time() - start:.2f}s)") + except Exception as e: + log(f" FAILED: {e}") + return 1 + + # Step 7: Cleanup manager + log("Step 7: Cleaning up manager...") + start = time.time() + try: + manager.clean_up() + log(f" Manager cleaned up ({time.time() - start:.2f}s)") + except Exception as e: + log(f" FAILED: {e}") + return 1 + + log("=== All steps completed successfully ===") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit_tests/ces/diagnose_simple.py b/tests/unit_tests/ces/diagnose_simple.py new file mode 100644 index 000000000..cb8994d79 --- /dev/null +++ b/tests/unit_tests/ces/diagnose_simple.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +import sys + +sys.path.insert(0, ".") + +print("1. Starting", flush=True) + +print("2. Importing code_execution_service_factory...", flush=True) +from taskweaver.ces import code_execution_service_factory + +print(" Done", flush=True) + +print("3. Creating temp dir...", flush=True) +import tempfile + +tmp_dir = tempfile.mkdtemp(prefix="ces_diag_") +print(f" {tmp_dir}", flush=True) + +print("4. Creating manager...", flush=True) +manager = code_execution_service_factory(tmp_dir) +print(f" Type: {type(manager).__name__}", flush=True) + +print("5. Getting session client...", flush=True) +session = manager.get_session_client("diag-session") +print(f" Type: {type(session).__name__}", flush=True) + +print("6. Starting session (may hang here)...", flush=True) +session.start() +print(" Session started!", flush=True) + +print("7. Executing code...", flush=True) +result = session.execute_code("test-1", "'hello'") +print(f" Success: {result.is_success}, Output: {result.output}", flush=True) + +print("8. Stopping...", flush=True) +session.stop() +manager.clean_up() +print("DONE", flush=True) diff --git a/tests/unit_tests/ces/test_context.py b/tests/unit_tests/ces/test_context.py new file mode 100644 index 000000000..40676a4ea --- /dev/null +++ b/tests/unit_tests/ces/test_context.py @@ -0,0 +1,291 @@ +"""Unit tests for ExecutorPluginContext, focusing on extract_visible_variables.""" + +import types +from typing import Any, Dict +from unittest.mock import MagicMock + +import pytest + +from taskweaver.ces.runtime.context import ExecutorPluginContext + + +def _has_numpy() -> bool: + """Check if numpy is available.""" + try: + import numpy # noqa: F401 + + return True + except ImportError: + return False + + +class TestExtractVisibleVariables: + """Tests for the extract_visible_variables method.""" + + @pytest.fixture() + def context(self) -> ExecutorPluginContext: + """Create an ExecutorPluginContext with a mocked executor.""" + mock_executor = MagicMock() + mock_executor.session_var = {} + return ExecutorPluginContext(mock_executor) + + def test_string_variable_no_extra_quotes(self, context: ExecutorPluginContext) -> None: + """Test that string variables are rendered without extra quotes.""" + local_ns: Dict[str, Any] = {"filename": "data.csv"} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + name, rendered = result[0] + assert name == "filename" + assert rendered == "data.csv" + assert rendered != "'data.csv'" + + def test_string_with_spaces(self, context: ExecutorPluginContext) -> None: + """Test string with spaces renders correctly.""" + local_ns: Dict[str, Any] = {"path": "/path/to/my file.txt"} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert rendered == "/path/to/my file.txt" + + def test_empty_string(self, context: ExecutorPluginContext) -> None: + """Test empty string renders as empty.""" + local_ns: Dict[str, Any] = {"empty": ""} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert rendered == "" + + def test_string_with_quotes_inside(self, context: ExecutorPluginContext) -> None: + """Test string containing quotes renders correctly.""" + local_ns: Dict[str, Any] = {"quoted": "He said 'hello'"} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert rendered == "He said 'hello'" + + def test_multiline_string(self, context: ExecutorPluginContext) -> None: + """Test multiline string renders with actual newlines.""" + local_ns: Dict[str, Any] = {"text": "line1\nline2"} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert rendered == "line1\nline2" + assert "\\n" not in rendered + + def test_integer_uses_repr(self, context: ExecutorPluginContext) -> None: + """Test integer variables use repr (same result as str for int).""" + local_ns: Dict[str, Any] = {"count": 42} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert rendered == "42" + + def test_float_uses_repr(self, context: ExecutorPluginContext) -> None: + """Test float variables use repr.""" + local_ns: Dict[str, Any] = {"pi": 3.14159} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert rendered == "3.14159" + + def test_list_uses_repr(self, context: ExecutorPluginContext) -> None: + """Test list variables use repr to show brackets.""" + local_ns: Dict[str, Any] = {"items": [1, 2, 3]} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert rendered == "[1, 2, 3]" + + def test_dict_uses_repr(self, context: ExecutorPluginContext) -> None: + """Test dict variables use repr to show braces.""" + local_ns: Dict[str, Any] = {"data": {"key": "value"}} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert rendered == "{'key': 'value'}" + + def test_boolean_uses_repr(self, context: ExecutorPluginContext) -> None: + """Test boolean variables use repr.""" + local_ns: Dict[str, Any] = {"flag": True} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert rendered == "True" + + def test_none_uses_repr(self, context: ExecutorPluginContext) -> None: + """Test None uses repr.""" + local_ns: Dict[str, Any] = {"nothing": None} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert rendered == "None" + + def test_ignores_private_variables(self, context: ExecutorPluginContext) -> None: + """Test that variables starting with underscore are ignored.""" + local_ns: Dict[str, Any] = { + "_private": "hidden", + "__dunder": "also hidden", + "public": "visible", + } + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + name, _ = result[0] + assert name == "public" + + def test_ignores_builtin_names(self, context: ExecutorPluginContext) -> None: + """Test that builtin IPython names are ignored.""" + local_ns: Dict[str, Any] = { + "In": [], + "Out": {}, + "__builtins__": {}, + "user_var": "visible", + } + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + name, _ = result[0] + assert name == "user_var" + + def test_ignores_common_imports(self, context: ExecutorPluginContext) -> None: + """Test that common library aliases (pd, np, plt) are ignored.""" + local_ns: Dict[str, Any] = { + "pd": MagicMock(), + "np": MagicMock(), + "plt": MagicMock(), + "my_data": "visible", + } + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + name, _ = result[0] + assert name == "my_data" + + def test_ignores_modules(self, context: ExecutorPluginContext) -> None: + """Test that module objects are ignored.""" + local_ns: Dict[str, Any] = { + "os_module": types.ModuleType("os"), + "user_var": "visible", + } + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + name, _ = result[0] + assert name == "user_var" + + def test_ignores_functions(self, context: ExecutorPluginContext) -> None: + """Test that function objects are ignored.""" + + def my_func() -> None: + pass + + local_ns: Dict[str, Any] = { + "my_func": my_func, + "user_var": "visible", + } + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + name, _ = result[0] + assert name == "user_var" + + def test_truncates_long_values(self, context: ExecutorPluginContext) -> None: + """Test that values longer than 500 chars are truncated.""" + long_string = "x" * 1000 + local_ns: Dict[str, Any] = {"long": long_string} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert len(rendered) == 500 + assert rendered == "x" * 500 + + def test_multiple_variables(self, context: ExecutorPluginContext) -> None: + """Test extracting multiple variables of different types.""" + local_ns: Dict[str, Any] = { + "name": "Alice", + "age": 30, + "scores": [85, 90, 78], + } + result = context.extract_visible_variables(local_ns) + + assert len(result) == 3 + result_dict = dict(result) + assert result_dict["name"] == "Alice" + assert result_dict["age"] == "30" + assert result_dict["scores"] == "[85, 90, 78]" + + def test_updates_latest_variables(self, context: ExecutorPluginContext) -> None: + """Test that latest_variables is updated after extraction.""" + local_ns: Dict[str, Any] = {"var": "value"} + result = context.extract_visible_variables(local_ns) + + assert context.latest_variables == result + + def test_unrepresentable_value(self, context: ExecutorPluginContext) -> None: + """Test handling of values that raise on repr().""" + + class BadRepr: + def __repr__(self) -> str: + raise ValueError("Cannot repr") + + local_ns: Dict[str, Any] = {"bad": BadRepr()} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert rendered == "" + + +class TestExtractVisibleVariablesWithNumpy: + """Tests for extract_visible_variables with numpy arrays.""" + + @pytest.fixture() + def context(self) -> ExecutorPluginContext: + """Create an ExecutorPluginContext with a mocked executor.""" + mock_executor = MagicMock() + mock_executor.session_var = {} + return ExecutorPluginContext(mock_executor) + + @pytest.mark.skipif( + not _has_numpy(), + reason="numpy not installed", + ) + def test_numpy_array_rendering(self, context: ExecutorPluginContext) -> None: + """Test numpy array is rendered with shape and dtype info.""" + import numpy as np + + local_ns: Dict[str, Any] = {"arr": np.array([1, 2, 3])} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert "ndarray" in rendered + assert "shape=(3,)" in rendered + assert "dtype=int" in rendered + + @pytest.mark.skipif( + not _has_numpy(), + reason="numpy not installed", + ) + def test_numpy_2d_array(self, context: ExecutorPluginContext) -> None: + """Test 2D numpy array rendering.""" + import numpy as np + + local_ns: Dict[str, Any] = {"matrix": np.array([[1, 2], [3, 4]])} + result = context.extract_visible_variables(local_ns) + + assert len(result) == 1 + _, rendered = result[0] + assert "shape=(2, 2)" in rendered diff --git a/tests/unit_tests/ces/test_execution_client.py b/tests/unit_tests/ces/test_execution_client.py index c3e83d34a..a2661700e 100644 --- a/tests/unit_tests/ces/test_execution_client.py +++ b/tests/unit_tests/ces/test_execution_client.py @@ -599,6 +599,96 @@ def test_close(self, mock_client_class: MagicMock) -> None: mock_client.close.assert_called_once() +class TestExecutionClientUploadFile: + """Tests for file upload functionality.""" + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_upload_file_success(self, mock_client_class: MagicMock) -> None: + """Test successful file upload.""" + import base64 + + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={ + "filename": "test.csv", + "status": "uploaded", + "path": "/workspace/test-session/cwd/test.csv", + }, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test-session", server_url="http://localhost:8000") + content = b"col1,col2\n1,2\n3,4" + result = client.upload_file("test.csv", content) + + assert result == "/workspace/test-session/cwd/test.csv" + + # Verify the POST call + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert call_args[0][0] == "/api/v1/sessions/test-session/files" + json_body = call_args[1]["json"] + assert json_body["filename"] == "test.csv" + assert json_body["encoding"] == "base64" + # Verify content is base64 encoded + assert json_body["content"] == base64.b64encode(content).decode("ascii") + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_upload_file_binary_content(self, mock_client_class: MagicMock) -> None: + """Test uploading binary file content.""" + import base64 + + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={"filename": "image.png", "status": "uploaded", "path": "/workspace/image.png"}, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + # Binary content with non-UTF8 bytes + binary_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + client.upload_file("image.png", binary_content) + + call_args = mock_client.post.call_args + json_body = call_args[1]["json"] + # Verify binary content is correctly base64 encoded + assert json_body["content"] == base64.b64encode(binary_content).decode("ascii") + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_upload_file_error(self, mock_client_class: MagicMock) -> None: + """Test file upload error handling.""" + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=404, + json_data={"detail": "Session not found"}, + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + + with pytest.raises(ExecutionClientError) as exc_info: + client.upload_file("test.csv", b"content") + assert exc_info.value.status_code == 404 + assert "Session not found" in str(exc_info.value) + + @patch("taskweaver.ces.client.execution_client.httpx.Client") + def test_upload_file_empty_path_response(self, mock_client_class: MagicMock) -> None: + """Test upload when server returns empty path.""" + mock_client = MagicMock() + mock_client.post.return_value = MockResponse( + status_code=200, + json_data={"filename": "test.csv", "status": "uploaded"}, # No "path" field + ) + mock_client_class.return_value = mock_client + + client = ExecutionClient(session_id="test", server_url="http://localhost:8000") + result = client.upload_file("test.csv", b"content") + + assert result == "" # Should return empty string when path not in response + + class TestExecutionClientError: """Tests for ExecutionClientError exception.""" diff --git a/tests/unit_tests/ces/test_execution_service.py b/tests/unit_tests/ces/test_execution_service.py index 418b27067..3a826e3b9 100644 --- a/tests/unit_tests/ces/test_execution_service.py +++ b/tests/unit_tests/ces/test_execution_service.py @@ -115,9 +115,12 @@ def test_stop_cleans_up_on_error(self, mock_client_class: MagicMock) -> None: server_url="http://localhost:8000", ) client.start() - client.stop() - # Client should still be cleaned up + # Exception should propagate, but cleanup should still happen + with pytest.raises(Exception, match="Stop failed"): + client.stop() + + # Client should still be cleaned up despite the error assert client._client is None mock_exec_client.close.assert_called_once() @@ -264,6 +267,33 @@ def test_execute_code_not_started(self) -> None: with pytest.raises(RuntimeError, match="Client not started"): client.execute_code("exec-001", "code") + @patch("taskweaver.ces.manager.execution_service.ExecutionClient") + def test_upload_file(self, mock_client_class: MagicMock) -> None: + """Test uploading a file.""" + mock_exec_client = MagicMock() + mock_exec_client.upload_file.return_value = "/workspace/test/file.csv" + mock_client_class.return_value = mock_exec_client + + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + client.start() + result = client.upload_file("file.csv", b"col1,col2\n1,2") + + assert result == "/workspace/test/file.csv" + mock_exec_client.upload_file.assert_called_once_with("file.csv", b"col1,col2\n1,2") + + def test_upload_file_not_started(self) -> None: + """Test that upload_file raises when not started.""" + client = ExecutionServiceClient( + session_id="test", + server_url="http://localhost:8000", + ) + + with pytest.raises(RuntimeError, match="Client not started"): + client.upload_file("file.csv", b"content") + class TestExecutionServiceProvider: """Tests for ExecutionServiceProvider.""" @@ -341,6 +371,7 @@ def test_initialize_with_auto_start(self, mock_launcher_class: MagicMock) -> Non container=True, container_image="custom/image", startup_timeout=30.0, + kill_existing=True, ) mock_launcher.start.assert_called_once() assert provider._initialized is True diff --git a/tests/unit_tests/ces/test_server_launcher.py b/tests/unit_tests/ces/test_server_launcher.py index b5f93be6a..15ab3c26e 100644 --- a/tests/unit_tests/ces/test_server_launcher.py +++ b/tests/unit_tests/ces/test_server_launcher.py @@ -1,6 +1,7 @@ """Unit tests for ServerLauncher.""" import os +import signal import subprocess from unittest.mock import MagicMock, patch @@ -116,12 +117,12 @@ class TestServerLauncherStartSubprocess: @patch("taskweaver.ces.client.server_launcher.httpx.get") def test_start_when_already_running(self, mock_get: MagicMock) -> None: - """Test start is no-op when server already running.""" + """Test start is no-op when server already running and kill_existing=False.""" mock_response = MagicMock() mock_response.status_code = 200 mock_get.return_value = mock_response - launcher = ServerLauncher() + launcher = ServerLauncher(kill_existing=False) launcher.start() assert launcher._started is True @@ -329,7 +330,7 @@ def test_start_container_success(self, mock_get: MagicMock) -> None: call_kwargs = mock_client.containers.run.call_args[1] assert call_kwargs["detach"] is True assert call_kwargs["remove"] is True - assert "9000/tcp" in call_kwargs["ports"] + assert call_kwargs["ports"] == {"8000/tcp": 9000} assert call_kwargs["environment"]["TASKWEAVER_SERVER_API_KEY"] == "secret" @@ -413,8 +414,13 @@ class TestServerLauncherStop: @patch("taskweaver.ces.client.server_launcher.httpx.get") @patch("subprocess.Popen") + @patch("os.name", "posix") + @patch("os.killpg") + @patch("os.getpgid") def test_stop_subprocess( self, + mock_getpgid: MagicMock, + mock_killpg: MagicMock, mock_popen: MagicMock, mock_get: MagicMock, ) -> None: @@ -428,6 +434,7 @@ def test_stop_subprocess( mock_process.poll.return_value = None mock_process.wait.return_value = 0 mock_popen.return_value = mock_process + mock_getpgid.return_value = 12345 launcher = ServerLauncher() launcher.start() @@ -435,7 +442,8 @@ def test_stop_subprocess( assert launcher._started is False assert launcher._process is None - mock_process.terminate.assert_called() + # On Unix, should send SIGTERM to process group + mock_killpg.assert_called() @patch("taskweaver.ces.client.server_launcher.httpx.get") @patch("subprocess.Popen") @@ -470,8 +478,13 @@ def test_stop_subprocess_unix( @patch("taskweaver.ces.client.server_launcher.httpx.get") @patch("subprocess.Popen") + @patch("os.name", "posix") + @patch("os.killpg") + @patch("os.getpgid") def test_stop_subprocess_force_kill( self, + mock_getpgid: MagicMock, + mock_killpg: MagicMock, mock_popen: MagicMock, mock_get: MagicMock, ) -> None: @@ -488,12 +501,16 @@ def test_stop_subprocess_force_kill( 0, # Second wait succeeds after force kill ] mock_popen.return_value = mock_process + mock_getpgid.return_value = 12345 launcher = ServerLauncher() launcher.start() launcher.stop() - mock_process.kill.assert_called() + assert mock_killpg.call_count == 2 + calls = mock_killpg.call_args_list + assert calls[0][0][1] == signal.SIGTERM + assert calls[1][0][1] == signal.SIGKILL def test_stop_not_started(self) -> None: """Test stop is no-op when not started.""" diff --git a/tests/unit_tests/ces/test_session.py b/tests/unit_tests/ces/test_session.py index 6a2c504af..4a701909f 100644 --- a/tests/unit_tests/ces/test_session.py +++ b/tests/unit_tests/ces/test_session.py @@ -71,6 +71,8 @@ class SessionSpec: ] +@pytest.mark.integration +@pytest.mark.timeout(60) @pytest.mark.parametrize( "session_spec", spec_def, diff --git a/tests/unit_tests/ces/test_session_manager.py b/tests/unit_tests/ces/test_session_manager.py index 758d9524a..03424e5ab 100644 --- a/tests/unit_tests/ces/test_session_manager.py +++ b/tests/unit_tests/ces/test_session_manager.py @@ -127,14 +127,16 @@ def test_create_session_with_cwd( self, mock_env_class: MagicMock, manager: ServerSessionManager, + tmp_path: str, ) -> None: """Test creating a session with custom cwd.""" mock_env = MagicMock() mock_env_class.return_value = mock_env - session = manager.create_session("test-session", cwd="/custom/path") + custom_cwd = os.path.join(str(tmp_path), "custom", "path") + session = manager.create_session("test-session", cwd=custom_cwd) - assert session.cwd == "/custom/path" + assert session.cwd == custom_cwd @patch("taskweaver.ces.server.session_manager.Environment") def test_create_duplicate_session_raises( @@ -479,6 +481,158 @@ def test_cleanup_all_with_errors( assert manager.active_session_count == 0 +class TestServerSessionManagerUploadFile: + """Tests for file upload functionality.""" + + @pytest.fixture() + def manager(self, tmp_path: str) -> ServerSessionManager: + """Create a ServerSessionManager with a temp work dir.""" + return ServerSessionManager( + env_id="test-env", + work_dir=str(tmp_path), + ) + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_upload_file_success( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test successful file upload.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + session = manager.create_session("test-session") + + # Upload a file + content = b"col1,col2\n1,2\n3,4" + result = manager.upload_file("test-session", "data.csv", content) + + # Verify file was written to session's cwd + assert result == os.path.join(session.cwd, "data.csv") + assert os.path.isfile(result) + + # Verify content + with open(result, "rb") as f: + assert f.read() == content + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_upload_file_binary_content( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test uploading binary file content.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + manager.create_session("test-session") + + # Binary content with non-UTF8 bytes + binary_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + result = manager.upload_file("test-session", "image.png", binary_content) + + # Verify file was written correctly + with open(result, "rb") as f: + assert f.read() == binary_content + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_upload_file_nonexistent_session( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test that uploading to non-existent session raises KeyError.""" + with pytest.raises(KeyError, match="not found"): + manager.upload_file("nonexistent", "file.csv", b"content") + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_upload_file_path_traversal_prevention( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test that path traversal attacks are prevented.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + session = manager.create_session("test-session") + + # Attempt path traversal + content = b"malicious content" + result = manager.upload_file("test-session", "../../../etc/passwd", content) + + # Should sanitize to just "passwd" and write to session's cwd + assert result == os.path.join(session.cwd, "passwd") + assert os.path.isfile(result) + + # Verify file is in session's cwd, not in /etc + assert session.cwd in result + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_upload_file_with_subdirectory_in_name( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test that subdirectory paths in filename are sanitized.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + session = manager.create_session("test-session") + + # Filename with subdirectory + content = b"data" + result = manager.upload_file("test-session", "subdir/file.csv", content) + + # Should strip the subdirectory + assert result == os.path.join(session.cwd, "file.csv") + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_upload_file_updates_activity( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test that upload updates session activity timestamp.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + session = manager.create_session("test-session") + old_activity = session.last_activity + + # Small delay to ensure different timestamp + import time + + time.sleep(0.01) + + manager.upload_file("test-session", "file.csv", b"content") + + assert session.last_activity >= old_activity + + @patch("taskweaver.ces.server.session_manager.Environment") + def test_upload_file_overwrite_existing( + self, + mock_env_class: MagicMock, + manager: ServerSessionManager, + ) -> None: + """Test that uploading overwrites existing file.""" + mock_env = MagicMock() + mock_env_class.return_value = mock_env + + manager.create_session("test-session") + + # Upload initial file + manager.upload_file("test-session", "file.txt", b"original content") + + # Upload again with same name + result = manager.upload_file("test-session", "file.txt", b"new content") + + # Verify content was overwritten + with open(result, "rb") as f: + assert f.read() == b"new content" + + class TestServerSessionManagerThreadSafety: """Tests for thread safety of ServerSessionManager.""" diff --git a/tests/unit_tests/data/prompts/generator_plugin_only.yaml b/tests/unit_tests/data/prompts/generator_plugin_only.yaml index 4f69f6cc0..a4c97e02f 100644 --- a/tests/unit_tests/data/prompts/generator_plugin_only.yaml +++ b/tests/unit_tests/data/prompts/generator_plugin_only.yaml @@ -1,4 +1,5 @@ version: 0.1 content: |- {ROLE_NAME} can understand the user request and leverage pre-defined tools to complete tasks. + {ROLE_NAME} must say "I can't do that" if the user asks for something that is not possible by the pre-defined tools. diff --git a/tests/unit_tests/data/prompts/generator_prompt.yaml b/tests/unit_tests/data/prompts/generator_prompt.yaml index aa7da4d0c..c5f441601 100644 --- a/tests/unit_tests/data/prompts/generator_prompt.yaml +++ b/tests/unit_tests/data/prompts/generator_prompt.yaml @@ -36,7 +36,6 @@ response_json_schema: |- "properties": { "thought": { "type": "string", - "maxLength": 1000, "description": "The thoughts before generating the code." }, "reply_type": { @@ -49,7 +48,6 @@ response_json_schema: |- }, "reply_content": { "type": "string", - "minLength": 10, "description": "The actual content of the response. If the reply_type is 'python', the content should be a valid python code snippet. Make sure escaping the special characters (e.g., '\\', '/', and '\"') in the strings for JSON format." } }, @@ -57,15 +55,17 @@ response_json_schema: |- "thought", "reply_type", "reply_content" - ] + ], + "additionalProperties": false } }, "required": [ "response" - ] + ], + "additionalProperties": false } - + conversation_head: |- ============================== ## Conversation Start diff --git a/tests/unit_tests/data/prompts/planner_prompt.yaml b/tests/unit_tests/data/prompts/planner_prompt.yaml index 84133a835..32ab3a954 100644 --- a/tests/unit_tests/data/prompts/planner_prompt.yaml +++ b/tests/unit_tests/data/prompts/planner_prompt.yaml @@ -1,4 +1,4 @@ -version: 0.4 +version: 0.5 instruction_template: |- You are the Planner who can coordinate Workers to finish the user task. @@ -20,17 +20,28 @@ instruction_template: |- {worker_intro} ## Planner Character - - Planner's main job is to make planning and to instruct Workers to resolve the request from the User. - - Planner can conduct basic analysis (e.g., comprehension, extraction, etc.) to solve simple problems after reading the messages from the User and the Workers. - - Planner should first try to solve the task by itself before reaching out to the Workers for their special expertise. - - Planner can assign different subtasks to different Workers, and each subtask should be assigned to only one Worker. - - Planner must reject the User's request if it contains potential security risks or illegal activities. + - Planner's main job is to make planning and collaborate with Workers to resolve the request from the User. + - Planner has the following cognitive skills: + + Reasoning: Analyzes user requests, worker responses, and environmental context to solve problems. + + Reading and Comprehension: Understands and interprets unstructured or structured information accurately. + + Pattern Recognition/Matching: Identifies and utilizes patterns in information. + + Comparison: Evaluates and contrasts information to draw conclusions. + + Adaptability: Adjusts plans and strategies accordingly based on new information or observations. + + Communication: Effectively conveys and receives information. + - Planner should use its skills before considering the involvement of Workers for direct engagement and immediate results. + - Planner can assign subtasks to Workers when the task requires specific skills beyond the Planner's capabilities, and each subtask should be assigned to only one Worker. - Planner should ask the User to provide additional information critical for problem solving, but only after trying the best. - - Planner can talk to the User and Workers by specifying the `send_to` field in the response, but MUST NOT talk to the Planner itself. - Planner should refine the plan according to its observations from the replies of the Workers or the new requests of User. - - Planner needs to inform Workers on the User's request, the current step, and necessary information to complete the task. - - Planner must check the Worker's response and provide feedback to the Worker if the response is incorrect or incomplete. + - Planner must thoroughly review Worker's response and provide feedback to the Worker if the response is incorrect or incomplete. - Planner can ignore the permission or file access issues since Workers are powerful and can handle them. + - Planner must reject the User's request if it contains potential security risks or illegal activities. + + ## Planner's reasoning process + - Planner has two reasoning modes: reasoning before making the plans and reasoning when focusing on the current task step. + - Planner should reason before making the plans which is about why the Planner makes the plan in this way. + - When Planner is focused on the current task step, Planner have two options: + 1. Planner send a message to a Worker to execute the task step. + 2. Planner use its own skills to complete the task step, which is recommended when the task step is simple. ## Planner's planning process You need to make a step-by-step plan to complete the User's task. @@ -44,6 +55,19 @@ instruction_template: |- - Keeping steps with interactive dependency separate (they require intermediate results before proceeding) - The final plan should be concise and actionable, without dependency annotations + ## Planner's communication process + - Planner should communicate with the User and Workers by specifying the `send_to` field in the response. + - Planner should not talk to itself. + - Planner needs to inform Workers on the User's request, the current step, and necessary information to complete the task. + - Planner should provide the reason before talking to the User in the response: + + Completed: The task is completed successfully. + + Clarification: The User's request is unclear or ambiguous and requires clarification. + + AdditionalInformation: The User's request is incomplete or missing critical information and requires additional information. + + SecurityRisks: The User's request contains potential security risks or illegal activities and requires rejection. + + TaskFailure: The task fails after few attempts and requires the User's confirmation to proceed. + + UserCancelled: The User has explicitly cancelled the operation (e.g., declined code execution confirmation). Do NOT retry or continue the task - stop immediately and acknowledge the cancellation. + + ### Examples of planning The examples below show how to think about task decomposition and create compact plans: @@ -81,6 +105,8 @@ instruction_template: |- ## Planner's useful tips - When the request involves loading a file or pulling a table from db, Planner should always set the first subtask to reading the content to understand the structure or schema of the data. - When the request involves text analysis, Planner should always set the first subtask to read and print the text content to understand its content structure. + - When the request involves read instructions for task execution, Planner should always update the plan to the steps and sub-steps in the instructions and then follow the updated plan to execute necessary actions. + - When a Worker responds with "Code execution was cancelled by user" or similar cancellation message, Planner must immediately stop the task with stop="UserCancelled" and NOT retry or attempt alternative approaches. ## Planner's response format - Planner must strictly format the response into the following JSON object: @@ -99,6 +125,10 @@ response_json_schema: |- "response": { "type": "object", "properties": { + "plan_reasoning": { + "type": "string", + "description": "The reasoning of the Planner's decision. It should include the analysis of the User's request, the Workers' responses, and the current environment context." + }, "plan": { "type": "string", "description": "The step-by-step plan to complete the User's task. Steps with sequential or no dependency should be merged. Steps with interactive dependency should be kept separate." @@ -107,9 +137,10 @@ response_json_schema: |- "type": "string", "description": "The current step Planner is executing." }, - "review": { + "stop": { "type": "string", - "description": "The review of the current step. If the Worker's response is incorrect or incomplete, Planner should provide feedback to the Worker." + "description": "The stop reason when the Planner needs to talk to the User. Set it to 'InProcess' if the Planner is not talking to the User.", + "enum": ["InProcess", "Completed", "Clarification", "AdditionalInformation", "SecurityRisks", "TaskFailure", "UserCancelled"] }, "send_to": { "type": "string", @@ -121,14 +152,18 @@ response_json_schema: |- } }, "required": [ + "plan_reasoning", "plan", "current_plan_step", + "stop", "send_to", "message" - ] + ], + "additionalProperties": false } }, "required": [ "response" - ] + ], + "additionalProperties": false } diff --git a/tests/unit_tests/test_planner.py b/tests/unit_tests/test_planner.py index 0835ac1d3..655e79c5e 100644 --- a/tests/unit_tests/test_planner.py +++ b/tests/unit_tests/test_planner.py @@ -103,7 +103,7 @@ def test_compose_prompt(): ) post2.add_attachment( Attachment.create( - AttachmentType.init_plan, + AttachmentType.plan_reasoning, "1. load the data file\n2. count the rows of the loaded data \n" "3. report the result to the user ", ), @@ -138,7 +138,7 @@ def test_compose_prompt(): post4.add_attachment( Attachment.create( - AttachmentType.init_plan, + AttachmentType.plan_reasoning, "1. load the data file\n2. count the rows of the loaded data \n3. report the result " "to the user ", ), @@ -184,7 +184,7 @@ def test_compose_prompt(): ) assert messages[2]["role"] == "assistant" assert messages[2]["content"] == ( - '{"response": {"init_plan": "1. load the data file\\n2. count the rows of the ' + '{"response": {"plan_reasoning": "1. load the data file\\n2. count the rows of the ' "loaded data \\n3. report the result to the user ", "plan": "1. instruct CodeInterpreter to load the data file ' 'and count the rows of the loaded data\\n2. report the result to the user", ' @@ -201,7 +201,7 @@ def test_compose_prompt(): ) assert messages[4]["role"] == "assistant" assert messages[4]["content"] == ( - '{"response": {"init_plan": "1. load the data file\\n2. count the rows of the ' + '{"response": {"plan_reasoning": "1. load the data file\\n2. count the rows of the ' "loaded data \\n3. report the result to the user ", "plan": "1. instruct CodeInterpreter to load the data file ' 'and count the rows of the loaded data\\n2. report the result to the user", '