Skip to content
Open
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
119 changes: 119 additions & 0 deletions cookbook/12_context/12_github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""
GitHub Context Provider
=======================

GitHubContextProvider exposes two tools to the calling agent:

- `query_<id>(question)` — read a GitHub repo: navigate files, search
content, inspect git history. Backed by a sub-agent with read-only
Workspace + git tools, scoped to a local clone.
- `update_<id>(instruction)` — make a change and open a pull request.
Backed by a sub-agent with full Workspace + git tools, scoped to a
per-session worktree on a `<prefix>/<task>` branch. The agent can
never push to the default branch — branch-prefix safety enforces it.

This cookbook always runs the read prompt against a public repo. If
you set `GITHUB_WRITE_REPO=owner/yours` (a repo you own and don't mind
a tiny PR landing in) it also runs a write prompt that opens a PR
adding a noop line to a file. Without it, the write demo is skipped
so a casual `python cookbook/12_context/12_github.py` never spams a
real repo.

Requires:
OPENAI_API_KEY
gh (GitHub CLI on PATH; only needed for the write demo)

Optional:
GITHUB_TOKEN (required for private repos and for writes)
GITHUB_WRITE_REPO (e.g. `your-name/scratch`) — opt in to the write demo
"""

from __future__ import annotations

import asyncio
import os
import tempfile

from agno.agent import Agent
from agno.context.github import GitHubContextProvider
from agno.models.openai import OpenAIResponses

# ---------------------------------------------------------------------------
# Read provider — points at a small public repo so the demo is
# self-contained. `root` lives under the system temp dir so repeat
# runs don't pile up under your home directory.
# ---------------------------------------------------------------------------
read_root = os.path.join(tempfile.gettempdir(), "agno_github_context_demo")
gh_read = GitHubContextProvider(
repo="agno-agi/agno",
root=read_root,
branch="main",
id="agno_repo",
name="Agno repo",
model=OpenAIResponses(id="gpt-5.4-mini"),
)

read_agent = Agent(
model=OpenAIResponses(id="gpt-5.4"),
tools=gh_read.get_tools(),
instructions=gh_read.instructions(),
markdown=True,
)


async def main() -> None:
# ---- asetup clones (or fetches) the repo. Required before query/update.
await gh_read.asetup()
print(f"\ngh_read.status() = {gh_read.status()}\n")

# ---- Read path: always runs.
read_prompt = (
"Look at the README.md at the repo root and summarize what Agno is "
"in 2 sentences. Cite the file you read."
)
print(f"> {read_prompt}\n")
await read_agent.aprint_response(read_prompt)

# ---- Write path: opt in via env var.
write_repo = os.environ.get("GITHUB_WRITE_REPO")
if not write_repo:
print(
"\n[skipped write demo] Set GITHUB_WRITE_REPO=owner/yours to also "
"exercise the update path (it opens a small PR)."
)
await gh_read.aclose()
return

write_root = os.path.join(tempfile.gettempdir(), "agno_github_context_demo_write")
gh_write = GitHubContextProvider(
repo=write_repo,
root=write_root,
branch="main",
pr_branch_prefix="agno",
id="write_target",
name=f"Write target ({write_repo})",
model=OpenAIResponses(id="gpt-5.4-mini"),
)
await gh_write.asetup()

write_agent = Agent(
model=OpenAIResponses(id="gpt-5.4"),
tools=gh_write.get_tools(),
instructions=gh_write.instructions(),
markdown=True,
)

write_prompt = (
"Append a single line `<!-- demo: agno-context -->` to the bottom "
"of README.md, commit it, push the branch, and open a pull request "
"titled 'Demo: agno-context smoke test'. Then report the PR URL."
)
print(f"\n> {write_prompt}\n")
await write_agent.aprint_response(write_prompt)

await gh_write.aclose()
await gh_read.aclose()


if __name__ == "__main__":
asyncio.run(main())
6 changes: 6 additions & 0 deletions cookbook/12_context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Providers ship in this package:
| `SlackContextProvider` | A Slack workspace | `query_<id>`, `update_<id>` (separate read/write sub-agents; writer only gets `send_message` + the lookup tools it needs) |
| `MCPContextProvider` | One MCP server | `query_<id>` (sub-agent over the server's tools) or flat tools in `mode=tools` |
| `GDriveContextProvider` | Google Drive via service account | `query_<id>` (list / search / read sub-agent; all-drives aware) |
| `GitHubContextProvider` | A GitHub repo (cloned locally) | `query_<id>` (Workspace + git read tools), `update_<id>` (Workspace + git write tools, scoped to a per-session worktree; ends in a PR) |

## Cookbooks

Expand All @@ -45,6 +46,7 @@ Providers ship in this package:
| `09_web_plus_slack.py` | Compositional: Slack topics feed per-topic web searches |
| `10_custom_provider.py` | Subclass `ContextProvider` for your own source |
| `11_web_parallel_mcp.py` | Web research via Parallel's public MCP endpoint (keyless; `PARALLEL_API_KEY` raises the ceiling) |
| `12_github.py` | GitHub repo: read public repo (always) + optional edit-via-PR via `GITHUB_WRITE_REPO` |

## Run

Expand Down Expand Up @@ -82,4 +84,8 @@ OPENAI_API_KEY=... .venvs/demo/bin/python cookbook/12_context/08_multi_provider.
# Compositional demo (Slack topics -> per-topic web searches)
OPENAI_API_KEY=... EXA_API_KEY=... SLACK_BOT_TOKEN=xoxb-... \
.venvs/demo/bin/python cookbook/12_context/09_web_plus_slack.py

# GitHub repo: reads agno-agi/agno; set GITHUB_WRITE_REPO + GITHUB_TOKEN
# (and `gh` on PATH) to also exercise the edit-via-PR path
OPENAI_API_KEY=... .venvs/demo/bin/python cookbook/12_context/12_github.py
```
83 changes: 83 additions & 0 deletions cookbook/91_tools/workspace_tools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Workspace

A polished local-machine toolkit. Read / write / edit / move / delete / search /
shell, scoped to a `root` directory (paths that resolve outside it are rejected).
Destructive operations require human confirmation by default — AgentOS renders
these as approval cards in the run timeline; in a plain console you drive the
loop yourself.

This is a path-scoping boundary, not a process sandbox — the agent can still
read env vars, hit the network via shell, etc. For untrusted code, run the
agent inside a real sandbox (container, VM, Daytona).

## Quick reference

```python
from agno.tools.workspace import Workspace

# Default: reads auto-pass, writes/edits/moves/deletes/shell require confirmation.
tools = [Workspace(".")]

# Explicit partition for clarity (recommended for the homepage demo style):
tools = [
Workspace(
".",
allowed=["read", "list", "search"],
confirm=["write", "edit", "delete", "shell"],
)
]

# Read-only:
tools = [Workspace(".", allowed=["read", "list", "search"])]

# Defensive: also block writes-to-files-the-agent-hasn't-read:
tools = [Workspace(".", require_read_before_write=True)]
```

## Permission model

`allowed` and `confirm` are mutually exclusive partitions of short
aliases. An alias in `allowed` runs silently, an alias in `confirm`
requires approval, an alias in neither isn't registered, and an alias in both
raises `ValueError`. The full alias mapping:

| Alias | Registered tool name | What it does |
| -------- | -------------------- | --------------------------------------- |
| `read` | `read_file` | Read a file (line-numbered, optional range) |
| `list` | `list_files` | List a directory (optional glob, optional recursive with `max_depth`) |
| `search` | `search_content` | Recursive content grep |
| `write` | `write_file` | Create or overwrite a file (atomic) |
| `edit` | `edit_file` | Replace a substring (with `replace_all`)|
| `move` | `move_file` | Move or rename a file |
| `delete` | `delete_file` | Delete a file |
| `shell` | `run_command` | Run a shell command in `root` |

The aliases keep snippets compact; the registered tool names stay descriptive
so the LLM tool spec is self-explanatory.

## Notable behaviors

- **`read_file` returns line-numbered output** (`cat -n` style). Numbers reflect
actual file lines, so the agent can chain into `edit_file` precisely.
- **`list_files` returns rich entries**: each is `{path, type, size}`. Use
`recursive=True` (default `max_depth=3`) to walk the tree.
- **`edit_file` defaults to unique-or-fail**, with `replace_all=True` for renames.
- **`write_file` is atomic** — writes to `<file>.tmp`, then `os.replace`.
- **`run_command` strips ANSI codes** and tails to the last 100 lines (configurable).
- **`require_read_before_write=True`** (opt-in) blocks `write_file` / `edit_file` /
`move_file` / `delete_file` on existing files until the agent has read them
this session. Catches the "agent hallucinated the file's contents" bug.

## Examples in this folder

- `basic_usage.py` — agent reads a tmp file and writes a summary, with
confirmations disabled so the demo runs end-to-end.
- `with_confirmation.py` — same agent with the default safety on; you
approve each write at the console.

## Running

```bash
.venvs/demo/bin/python cookbook/91_tools/workspace_tools/basic_usage.py
.venvs/demo/bin/python cookbook/91_tools/workspace_tools/with_confirmation.py
```
41 changes: 41 additions & 0 deletions cookbook/91_tools/workspace_tools/TEST_LOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Test Log — workspace_tools

Tracks ad-hoc runs of the cookbooks in this folder. Update after each test
session. See the parent `cookbook/91_tools/TEST_LOG.md` for the format
convention.

---

### basic_usage.py

**Status:** PASS

**Date:** 2026-04-25

**Description:** Agent reads a tmp README.md, writes NOTES.md with a 2-line
summary, then lists files. Uses `confirm=[]` so all tool calls auto-pass.

**Result:** Agent called `read_file`, `write_file`, and `list_files` in the
expected order. `write_file` returned `Wrote 95 chars to NOTES.md`. Final
message confirmed both files exist. Tool call rendering in the run timeline
showed readable args (e.g. `path=README.md, start_line=1, end_line=200`).

---

### with_confirmation.py

**Status:** PASS

**Date:** 2026-04-25

**Description:** Agent reads a tmp draft.md and edits a typo. Uses default
partitions, so `read_file` auto-passes but `edit_file` pauses for approval.
Smoke-tested with `yes y | python …` to drain confirmation prompts.

**Result:** `read_file` ran silently. `edit_file` paused, surfaced
`tool_name=edit_file` and the proposed `tool_args` (path, old_str, new_str)
through the `active_requirements` API. After confirm, the edit applied:
`taht` → `that`. `requirement.confirm()` and `agent.continue_run(...)`
flow worked as expected.

---
46 changes: 46 additions & 0 deletions cookbook/91_tools/workspace_tools/basic_usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""
Workspace — basic usage
=======================

A polished local-machine toolkit: read/write/edit/delete/search/shell, scoped to
a local directory (path-scoped to a `root`). Destructive operations require
confirmation by default —
see ``with_confirmation.py`` for the pause/resume flow.

This example uses ``confirm=[]`` to disable confirmation so the agent
runs end-to-end without prompts. For production, leave the defaults on.
"""

import tempfile
from pathlib import Path

from agno.agent import Agent
from agno.models.openai import OpenAIResponses
from agno.tools.workspace import Workspace

# Use a clean tmp directory so the demo doesn't touch real files.
workspace = Path(tempfile.mkdtemp(prefix="workspace_demo_"))
(workspace / "README.md").write_text(
"# Demo workspace\n\n"
"This file lives in a tmp directory.\n"
"The agent below will read it and produce a summary file.\n"
)

agent = Agent(
model=OpenAIResponses(id="gpt-5.4"),
tools=[
Workspace(
str(workspace),
allowed=Workspace.ALL_TOOLS,
confirm=[],
)
],
markdown=True,
)

if __name__ == "__main__":
agent.print_response(
"Read README.md, then write a 2-line summary to NOTES.md. "
"After that, list the files to confirm both exist."
)
print(f"\nWorkspace: {workspace}")
70 changes: 70 additions & 0 deletions cookbook/91_tools/workspace_tools/with_confirmation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
Workspace — human-in-the-loop confirmation
==========================================

This is the default safety story. Reads run silently; writes/edits/deletes/shell
pause the run and surface a confirmation request. AgentOS renders these as
approval cards in its run timeline. In a plain console, you handle the loop
yourself — that's what this example shows.

Run this in a terminal so you can answer y/n at the prompts.
"""

import tempfile
from pathlib import Path

from agno.agent import Agent
from agno.db.sqlite import SqliteDb
from agno.models.openai import OpenAIResponses
from agno.tools.workspace import Workspace
from agno.utils import pprint
from rich.console import Console
from rich.prompt import Prompt

console = Console()

workspace = Path(tempfile.mkdtemp(prefix="workspace_hitl_"))
(workspace / "draft.md").write_text(
"# Draft\n\nThis draft has typos taht need fixing.\n"
)

agent = Agent(
model=OpenAIResponses(id="gpt-5.4"),
tools=[
Workspace(
str(workspace),
# Default partition: reads auto-pass, writes need approval.
)
],
db=SqliteDb(db_file="tmp/workspace_hitl.db"),
markdown=True,
)


def _drain_pauses(run_response):
"""Approve every pending tool call until the run completes."""
while run_response.is_paused:
for requirement in run_response.active_requirements:
if requirement.needs_confirmation:
te = requirement.tool_execution
console.print(
f"\n[yellow]Tool[/] [bold blue]{te.tool_name}[/] wants to run with args:\n {te.tool_args}"
)
choice = Prompt.ask("Confirm?", choices=["y", "n"], default="y")
if choice.strip().lower() == "n":
requirement.reject()
else:
requirement.confirm()
run_response = agent.continue_run(
run_id=run_response.run_id,
requirements=run_response.requirements,
)
return run_response


if __name__ == "__main__":
initial = agent.run("Read draft.md and fix the typo on the line about typos.")
final = _drain_pauses(initial)
pprint.pprint_run_response(final)
print(f"\nWorkspace: {workspace}")
print(f"draft.md after edit:\n{(workspace / 'draft.md').read_text()}")
Empty file added cookbook/99_docs/__init__.py
Empty file.
Empty file.
Loading
Loading