Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .github/workflows/ci_cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ on:
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
strategy:
matrix:
python-version: [3.12, '3.10', '3.11']
Expand All @@ -37,7 +41,7 @@ jobs:
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.name,
repo: context.repo.repo,
body: '✅ Tests passed for Python ${{ matrix.python-version }}!'
})
- name: Upload coverage reports to Codecov
Expand All @@ -48,6 +52,10 @@ jobs:

test-examples:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
strategy:
matrix:
example: [deepsearch ]
Expand Down Expand Up @@ -78,12 +86,16 @@ jobs:
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.name,
repo: context.repo.repo,
body: '✅ Example tests passed for ${{ matrix.example }} (Python ${{ matrix.python-version }})!'
})

lint:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
steps:
- uses: actions/checkout@v4
- name: Set up Python
Expand All @@ -107,7 +119,7 @@ jobs:
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.name,
repo: context.repo.repo,
body: '✅ Linting passed!'
})

Expand Down
1 change: 1 addition & 0 deletions docs-astro/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default defineConfig({
items: [
{ label: 'Task Workers', slug: 'features/taskworkers' },
{ label: 'LLM Integration', slug: 'features/llm-integration' },
{ label: 'Workspaces & File Tools', slug: 'features/workspaces' },
{ label: 'Caching', slug: 'features/caching' },
{ label: 'Subgraphs', slug: 'features/subgraphs' },
],
Expand Down
14 changes: 14 additions & 0 deletions docs-astro/src/content/docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ Specialized workers for integrating Large Language Models into workflows, includ
- **CachedTaskWorker**: Base class for workers with caching
- **CachedLLMTaskWorker**: LLM worker with response caching

### Workspaces and File Tools
- **Workspace**: A sandboxed directory that every file path is resolved against
- **make_file_tools**: Builds read/write/edit/list/grep tools jailed to a Workspace
- **hash_files**: Content hash of the files matching glob patterns, for cache keys
- **WorkspaceTask**: Task that carries the workspace path through provenance
- **WorkspaceLLMTaskWorker**: CachedLLMTaskWorker with file tools and file-aware caching

### Advanced Workers
- **InitialTaskWorker**: Entry point for workflows
- **JoinedTaskWorker**: Aggregates multiple task results
Expand Down Expand Up @@ -68,6 +75,13 @@ from planai import (
CachedTaskWorker,
SubGraphWorker,

# Workspaces and file tools
Workspace,
make_file_tools,
hash_files,
WorkspaceTask,
WorkspaceLLMTaskWorker,

# Utilities
Dispatcher,
InputProvenance,
Expand Down
54 changes: 51 additions & 3 deletions docs-astro/src/content/docs/api/taskworker.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,15 @@ def extra_validation(self, response: Task, input_task: Task) -> Optional[str]:
return None
```

##### get_tools
```python
def get_tools(self, task: Task) -> Optional[List[Tool]]:
"""Tools for this task; defaults to the static ``tools`` field"""
return make_file_tools(Workspace(task.job_dir), read_only=True)
```

The `max_tool_rounds` field bounds the number of tool-calling rounds per request. When it is reached the model is asked for a final answer with tools disabled. See [Workspaces and File Tools](/features/workspaces/).

### Real-World Example

```python
Expand Down Expand Up @@ -314,6 +323,33 @@ class ExpensiveAnalysis(CachedLLMTaskWorker):
pass
```

### WorkspaceLLMTaskWorker

A `CachedLLMTaskWorker` that hands the LLM file tools jailed to a per-job directory found in the task's provenance:

```python
from planai import WorkspaceLLMTaskWorker, WorkspaceTask

class Editor(WorkspaceLLMTaskWorker):
prompt: str = "Fix the inconsistencies between the sections in draft/report.md"
llm_input_type: Type[Task] = WorkspaceTask
output_types: List[Type[Task]] = [EditSummary]
input_globs: List[str] = ["draft/*.md"]
max_tool_rounds: int = 40

def expected_output_files(self, task: WorkspaceTask) -> List[str]:
return ["draft/report.md"]
```

Fields: `read_only` (default `False`), `max_tool_rounds` (default `40`), `input_globs` (default empty), and `max_read_chars` (default `100000`).

Hooks:
- `get_workspace(task)` returns the `Workspace`, taken from the nearest task with a string `workspace` attribute; override it to source the directory elsewhere.
- `expected_output_files(task)` lists workspace-relative files whose absence invalidates a cache hit.
- `get_cache_salt(task)` salts the LLM response cache: the cache key for read-only workers, a fresh value per execution for workers whose tools write files.

See [Workspaces and File Tools](/features/workspaces/) for the full guide.

## CachedTaskWorker

Specialized worker for expensive operations that benefit from persistent caching:
Expand Down Expand Up @@ -381,6 +417,17 @@ def extra_cache_key(self, task: Task) -> str:
return f"{self.custom_setting}_{task.priority}"
```

#### _cache_hit_is_valid
```python
def _cache_hit_is_valid(
self, task: Task, cached_results: List[Tuple[str, Task]]
) -> bool:
"""Reject a cache hit based on state the key does not capture"""
return (Path(task.output_dir) / "index.json").exists()
```

Called on every cache hit before the cached results are published. Returning `False` logs the hit as no longer valid and runs `consume_work()` as on a miss.

### Real-World Example

```python
Expand Down Expand Up @@ -417,9 +464,10 @@ class DocumentAnalyzer(CachedTaskWorker):
#### Cache Hit
When input matches cached data:
1. `pre_consume_work()` is called
2. Cached results are published directly
3. `consume_work()` is **skipped**
4. `post_consume_work()` is called
2. `_cache_hit_is_valid()` may reject the hit, in which case the miss path runs
3. Cached results are published directly
4. `consume_work()` is **skipped**
5. `post_consume_work()` is called

#### Cache Miss
When no cached data exists:
Expand Down
27 changes: 27 additions & 0 deletions docs-astro/src/content/docs/features/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,33 @@ class VersionedCacheWorker(CachedTaskWorker):
return algorithm_version
```

## Caching Work That Touches Files

The cache key covers the input task, the prompt, and `extra_cache_key()`, but not the files a worker reads or writes. Two hooks close that gap:

- `hash_files(workspace, globs)` returns a content hash for the files matching a set of glob patterns. Return it from `extra_cache_key()` so a changed input file produces a new key.
- `_cache_hit_is_valid(task, cached_results)` runs on every cache hit and can reject it based on state the key does not capture. Return `False` when an output file from the cached run no longer exists, and the worker re-executes instead of replaying stale results.

```python
from pathlib import Path
from planai import CachedTaskWorker, hash_files

class Indexer(CachedTaskWorker):
output_types: List[Type[Task]] = [IndexBuilt]

def extra_cache_key(self, task: JobTask) -> str:
return hash_files(task.job_dir, ["docs/**/*.md"])

def _cache_hit_is_valid(self, task: JobTask, cached_results) -> bool:
return (Path(task.job_dir) / "index.json").exists()

def consume_work(self, task: JobTask):
# build docs/index.json from the markdown files
...
```

`WorkspaceLLMTaskWorker` implements both through its `input_globs` field and `expected_output_files()` hook. Note that the key is computed again when results are stored, after the worker ran, so a worker that changes files it also hashes is found by a later run over the changed files. See [Workspaces and File Tools](/features/workspaces/).

## Next Steps

- Learn about [Task Workers](/features/taskworkers/) that can be cached
Expand Down
36 changes: 35 additions & 1 deletion docs-astro/src/content/docs/features/llm-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ llm = llm_from_config(
)
```

### Anthropic

```python
# Set ANTHROPIC_API_KEY in your environment
llm = llm_from_config(
provider="anthropic",
model_name="claude-sonnet-5",
thinking={"type": "adaptive"}, # optional extended thinking
effort="high", # optional: low, medium, high, xhigh, max
)
```

Anthropic requests use structured outputs and prompt caching by default.

### Ollama (Local Models)

```python
Expand Down Expand Up @@ -176,6 +190,25 @@ class AssistantWorker(LLMTaskWorker):
# Tools are automatically registered with the LLM
```

### Per-Task Tools

The `tools` field binds the same tools to every task. Override `get_tools()` when the tools depend on the task, for example file tools jailed to a directory that arrives with the task:

```python
from planai import LLMTaskWorker, Workspace, make_file_tools

class RepoReviewer(LLMTaskWorker):
prompt = "Review the repository and report the three most important issues"
llm_input_type = RepoTask
output_types: List[Type[Task]] = [Review]
max_tool_rounds: int = 30

def get_tools(self, task: RepoTask):
return make_file_tools(Workspace(task.checkout_dir), read_only=True)
```

`max_tool_rounds` bounds how many rounds of tool calls the model may make within one request. When the limit is reached, the model is asked for its final structured answer with tools disabled, so a worker always produces an output task. See [Workspaces and File Tools](/features/workspaces/) for `WorkspaceLLMTaskWorker`, which wires up the file tools, workspace discovery, and file-aware caching for you.

## Streaming Responses

For real-time applications, enable response streaming:
Expand Down Expand Up @@ -402,4 +435,5 @@ class TokenTrackingWorker(LLMTaskWorker):
- Learn about [Prompt Engineering](/guide/prompts/) for better results
- Explore [Caching Strategies](/features/caching/) for cost optimization
- See [Examples](https://github.com/provos/planai/tree/main/examples) using LLMs
- Read about [Prompt Optimization](/cli/prompt-optimization/) tools
- Read about [Prompt Optimization](/cli/prompt-optimization/) tools
- Give the LLM files to work on with [Workspaces and File Tools](/features/workspaces/)
19 changes: 19 additions & 0 deletions docs-astro/src/content/docs/features/taskworkers.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,25 @@ class CachedAnalyzer(CachedLLMTaskWorker):
This helps during development to save model costs or avoiding repeat processing if a graph fails to run. Any changes
to the prompt, model or input_data will lead to a new cache key.

### WorkspaceLLMTaskWorker

A `CachedLLMTaskWorker` whose LLM can read, write, edit, list, and search files inside a per-job directory:

```python
from planai import WorkspaceLLMTaskWorker, WorkspaceTask

class ReportWriter(WorkspaceLLMTaskWorker):
prompt = "Read notes/*.md and write the report to report.md"
llm_input_type = WorkspaceTask
output_types: List[Type[Task]] = [ReportSummary]
input_globs: List[str] = ["notes/*.md"]

def expected_output_files(self, task: WorkspaceTask) -> List[str]:
return ["report.md"]
```

The directory comes from a `WorkspaceTask` upstream in the provenance chain, every path the model uses is jailed to it, and the cache is invalidated when the input files change or the output files are missing. See [Workspaces and File Tools](/features/workspaces/) for details.

### JoinedTaskWorker

Aggregates results from multiple tasks:
Expand Down
Loading
Loading