Skip to content

Commit 536f98d

Browse files
authored
Release/v0.0.5 (#11)
* refactor bash tool * refactor file tools * add agent tool * refactor human tool * remove session tracker from file edit tool * refactor the skill as a true tool * refactor: reorganize tool-related code into separate file and clean up unused definitions * refactor: enhance error handling for maximum iterations in agent loop * refactor: clean up unused imports and remove temporary file writing * docs: update README and tool documentation to include new agent tool and enhanced descriptions * fix
1 parent 17e48b4 commit 536f98d

55 files changed

Lines changed: 4056 additions & 2484 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ GOLANGCI_LINT := golangci-lint
1010
DOCKER_COMPOSE := docker compose
1111
E2E_OPENAI_BASE_URL ?= http://localhost:11434/v1
1212
E2E_OPENAI_API_KEY ?= ollama
13-
E2E_OPENAI_MODEL ?= minimax-m2.7:cloud
13+
E2E_OPENAI_MODEL ?= deepseek-v3.1:671b-cloud
1414
E2E_ANTHROPIC_BASE_URL ?= http://localhost:11434
1515
E2E_ANTHROPIC_AUTH_TOKEN ?= ollama
16-
E2E_ANTHROPIC_MODEL ?= minimax-m2.7:cloud
16+
E2E_ANTHROPIC_MODEL ?= deepseek-v3.1:671b-cloud
1717
E2E_EMBEDDING_MODEL ?= nomic-embed-text
1818

1919
# Default target

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,11 @@ Phero is organized into focused packages, each solving a specific problem:
104104
- **`mcp`** Model Context Protocol adapter for external tool integration
105105
- **`a2a`** Agent-to-Agent (A2A) protocol — expose agents as HTTP servers or call remote agents as tools
106106
- **`trace`** Typed observability events; `trace/text` for human-readable colorized output; `trace/jsonfile` for NDJSON file logging; `trace.NewLLM` for raw LLM call wrapping
107-
- **`tool/file`** File viewing and editing helpers (`view`, `create_file` with optional no-overwrite, `str_replace`)
108-
- **`tool/bash`** Bash command execution with blocklist, allowlist, timeout, and safe-mode guardrails
109-
- **`tool/human`** Human-in-the-loop input collection
107+
- **`tool/agent`** Create and run a sub-agent at runtime as a delegated tool
108+
- **`tool/file`** Filesystem tools (`read`, `write`, `edit`, `glob`, `grep`)
109+
- **`tool/bash`** Bash command execution with guardrails (blocklist, allowlist, timeout, safe mode) and background execution (`RunInBackground`, `bash_output`, `kill_shell`)
110+
- **`tool/human`** Structured user-interaction checkpoints; caller provides the interactor via `WithInteractor`
111+
- **`tool/skill`** Dispatcher-style SKILL.md loader tool that expands instructions in the main conversation
110112

111113

112114

@@ -133,7 +135,7 @@ Comprehensive examples are included in the [`examples/`](examples/) directory:
133135
| [Parallel Research](examples/parallel-research/) | Fan-out/fan-in workflow that runs multiple specialist researchers in parallel and merges their findings |
134136
| [Prompt Chaining](examples/prompt-chaining/) | Sequential multi-step prompting with a programmatic gate between stages |
135137
| [RAG Chatbot](examples/rag-chatbot/) | Terminal chatbot with semantic search over local documents using Qdrant |
136-
| [Skill](examples/skills/) | Discover SKILL.md files and expose them as callable agent tools |
138+
| [Skill](examples/skills/) | Use the `tool/skill` dispatcher to load SKILL.md instructions into the current conversation |
137139
| [Social Simulation](examples/social-simulation/) | Multi-agent social simulation with persona-driven actors and emergent interactions |
138140
| [MCP Integration](examples/mcp/) | Run an MCP server as a subprocess and expose its tools to agents |
139141
| [Playwright MCP](examples/playwright-mcp/) | Connect browser automation tools through MCP and orchestrate them from an agent |

agent/agent.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,9 @@ func (a *Agent) SetMemory(mem memory.Memory) {
152152

153153
// SetMaxIterations sets a maximum number of iterations for the agent loop.
154154
//
155-
// If the limit is reached, Run() returns an error. By default, there is no limit.
155+
// If the limit is reached, Run() returns ErrMaxIterationsReached together with
156+
// any partial text the model produced so far (the result may be non-nil even
157+
// when the error is set). By default, there is no limit.
156158
func (a *Agent) SetMaxIterations(maxIterations int) {
157159
a.maxIterations = maxIterations
158160
}
@@ -172,6 +174,10 @@ func (a *Agent) SetTracer(t trace.Tracer) {
172174
// The agent calls the LLM, executes any requested tool calls, and repeats until
173175
// the model returns a message without tool calls.
174176
//
177+
// If the maximum iterations limit is reached, the function returns
178+
// ErrMaxIterationsReached together with any partial text the model produced
179+
// (result may be non-nil even when err is set — use errors.Is to distinguish).
180+
//
175181
// If the run succeeds but saving the session to memory fails, the result is
176182
// still returned together with the save error joined via errors.Join.
177183
func (a *Agent) Run(ctx context.Context, parts ...llm.ContentPart) (result *Result, err error) {
@@ -225,7 +231,7 @@ func (a *Agent) Run(ctx context.Context, parts ...llm.ContentPart) (result *Resu
225231
for {
226232
iteration++
227233
if a.maxIterations > 0 && iteration > a.maxIterations {
228-
return nil, ErrMaxIterationsReached
234+
return partialResultFromSession(session), ErrMaxIterationsReached
229235
}
230236

231237
a.tracer.Trace(trace.AgentIterationEvent{
@@ -253,6 +259,28 @@ func (a *Agent) Run(ctx context.Context, parts ...llm.ContentPart) (result *Resu
253259
}
254260
}
255261

262+
// partialResultFromSession scans the session backwards and returns a Result
263+
// built from the last assistant message that contains at least one text part.
264+
// Returns nil if no such message exists.
265+
func partialResultFromSession(session []llm.Message) *Result {
266+
for i := len(session) - 1; i >= 0; i-- {
267+
msg := session[i]
268+
if msg.Role != llm.RoleAssistant {
269+
continue
270+
}
271+
textParts := make([]llm.ContentPart, 0, len(msg.Parts))
272+
for _, p := range msg.Parts {
273+
if p.Type == llm.ContentTypeText {
274+
textParts = append(textParts, p)
275+
}
276+
}
277+
if len(textParts) > 0 {
278+
return &Result{Parts: textParts}
279+
}
280+
}
281+
return nil
282+
}
283+
256284
// saveSession saves the conversation messages to memory, if memory is configured.
257285
func (a *Agent) saveSession(ctx context.Context, messages []llm.Message, sessionIndex int, stats *runStats) error {
258286
if a.memory == nil {

agent/errors.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ var (
2626
ErrDescriptionRequired = errors.New("agent description is required")
2727
// ErrNameRequired is returned when creating an agent with an empty name.
2828
ErrNameRequired = errors.New("agent name is required")
29-
// ErrMaxIterationsReached is returned when the agent loop reaches the maximum number of iterations.
29+
// ErrMaxIterationsReached is returned when the agent loop reaches the maximum number of
30+
// iterations. Run() will still return any partial text result alongside this error.
3031
ErrMaxIterationsReached = errors.New("maximum iterations reached")
3132
// ErrSessionSaveFailed is returned when the memory save after a successful run fails.
3233
ErrSessionSaveFailed = errors.New("session save failed")

examples/human-in-the-loop/README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ goal
1111
1212
DevOps Assistant
1313
14-
├─► ask_human("I plan to create a Dockerfile. Approve?")
14+
├─► user_interaction({ structured approval question })
1515
│ │
16-
human response
16+
structured user answer
1717
│ │
1818
│ approved? ──yes──► simulate_action("create Dockerfile")
1919
│ │
@@ -26,10 +26,11 @@ DevOps Assistant
2626
```
2727

2828
Key properties:
29-
- **`tool/human`** — the built-in `ask_human` tool presents a question on stdout and reads the answer from stdin.
29+
- **`tool/human`** — the built-in `user_interaction` tool validates a structured question payload (questions, options, multi-select flags) and returns structured answers.
30+
- **Host-provided interactor** — applications inject how answers are collected (CLI, web, IDE, etc.). In this example, a console callback renders options and reads stdin.
3031
- **Agent-controlled gate** — the agent itself decides when to ask; it is instructed never to simulate an action without prior approval.
3132
- **No hardcoded workflow** — the agent plans its own steps based on the goal; the human controls what actually runs.
32-
- **Interactive by design** — this example requires stdin interaction and is not suitable for CI.
33+
- **Interactive by design** — this sample callback uses stdin interaction and is not suitable for CI.
3334

3435
## Run
3536

@@ -47,11 +48,13 @@ go run ./examples/human-in-the-loop \
4748
-goal "Set up infrastructure for a Python FastAPI service: virtual environment, Dockerfile, nginx config."
4849
```
4950

50-
When prompted, respond with:
51-
- `yes` / `ok` / `proceed` — approve the action
52-
- `no` / `skip` — skip this action
53-
- `stop` / `abort` — stop all remaining actions
54-
- Any freeform text — the agent will adjust accordingly
51+
When prompted, select an option label or number:
52+
- `Approve` / `1` — approve the action
53+
- `Skip` / `2` — skip this action
54+
- `Modify` / `3` — ask the agent to revise first
55+
- `Stop` / `4` — stop all remaining actions
56+
57+
Optional free text can be provided as `other: <text>` in the same answer.
5558

5659
### Flags
5760

examples/human-in-the-loop/main.go

Lines changed: 139 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515
package main
1616

1717
import (
18+
"bufio"
1819
"context"
1920
"flag"
2021
"fmt"
22+
"io"
2123
"os"
24+
"strconv"
2225
"strings"
2326
"time"
2427

@@ -39,6 +42,18 @@ type ActionOutput struct {
3942
Status string `json:"status" jsonschema:"description=Outcome of the action: 'applied' or 'skipped'."`
4043
}
4144

45+
type consoleInteractor struct {
46+
reader *bufio.Reader
47+
writer io.Writer
48+
}
49+
50+
func newConsoleInteractor(reader io.Reader, writer io.Writer) *consoleInteractor {
51+
return &consoleInteractor{
52+
reader: bufio.NewReader(reader),
53+
writer: writer,
54+
}
55+
}
56+
4257
func main() {
4358
var goal string
4459
var timeout time.Duration
@@ -108,7 +123,9 @@ func buildLLMFromEnv() (llm.LLM, string) {
108123
}
109124

110125
func buildAgent(llmClient llm.LLM) (*agent.Agent, error) {
111-
humanTool, err := human.New()
126+
interactor := newConsoleInteractor(os.Stdin, os.Stdout)
127+
128+
humanTool, err := human.New(human.WithInteractor(interactor.Ask))
112129
if err != nil {
113130
return nil, err
114131
}
@@ -125,21 +142,26 @@ func buildAgent(llmClient llm.LLM) (*agent.Agent, error) {
125142
a, err := agent.New(llmClient, "DevOps Assistant", strings.TrimSpace(`You are a DevOps assistant helping a developer set up a new project.
126143
127144
You have two tools:
128-
- ask_human: use this to propose an action and ask for the developer's approval BEFORE doing anything.
129-
- simulate_action: use this ONLY after the human has explicitly approved the action.
130-
131-
Workflow for every action you intend to take:
132-
1. Call ask_human describing the action you plan to take and why.
133-
2. Read the human's response carefully.
134-
- If they approve (e.g. "yes", "ok", "proceed", "accept"): call simulate_action.
135-
- If they decline (e.g. "no", "skip", "skip it"): skip this action and move on.
136-
- If they ask for a modification: adjust the action accordingly, then ask again before simulating.
137-
- If they say "stop" or "abort": stop all remaining actions and summarise what was completed.
138-
3. Continue to the next action.
139-
140-
At the end, summarise which actions were applied and which were skipped.
141-
142-
Never simulate an action without explicit human approval.`))
145+
- user_interaction: use this to ask structured user questions before taking any consequential action.
146+
- simulate_action: use this only after explicit user approval.
147+
148+
For each action you plan to execute, call user_interaction with exactly one question:
149+
- header: "Approval"
150+
- question: describe the action and end with a question mark.
151+
- multiSelect: false
152+
- options:
153+
1) label "Approve" description "Proceed with the proposed action"
154+
2) label "Skip" description "Skip this action"
155+
3) label "Modify" description "User wants a modified version first"
156+
4) label "Stop" description "Stop the remaining plan"
157+
158+
Interpret the tool output from answers["Approval"]:
159+
- selection "Approve": call simulate_action.
160+
- selection "Skip": skip this action.
161+
- selection "Modify": revise action using the optional free-text field and ask again.
162+
- selection "Stop": stop all remaining actions and summarize.
163+
164+
Never call simulate_action without an explicit "Approve" selection.`))
143165
if err != nil {
144166
return nil, err
145167
}
@@ -166,3 +188,104 @@ func simulateAction(_ context.Context, in *ActionInput) (*ActionOutput, error) {
166188

167189
return &ActionOutput{Status: "applied"}, nil
168190
}
191+
192+
// Ask presents structured questions and returns selected option labels.
193+
func (c *consoleInteractor) Ask(ctx context.Context, in *human.Input) (map[string]human.Answer, error) {
194+
_ = ctx
195+
196+
answers := make(map[string]human.Answer, len(in.Questions))
197+
198+
for _, question := range in.Questions {
199+
if _, err := fmt.Fprintf(c.writer, "\n[%s] %s\n", question.Header, question.Question); err != nil {
200+
return nil, err
201+
}
202+
for idx, option := range question.Options {
203+
if _, err := fmt.Fprintf(c.writer, " %d) %s - %s\n", idx+1, option.Label, option.Description); err != nil {
204+
return nil, err
205+
}
206+
}
207+
208+
if question.MultiSelect {
209+
if _, err := fmt.Fprint(c.writer, "Select one or more options (comma separated labels or numbers). Optional free text: other: <text>\n> "); err != nil {
210+
return nil, err
211+
}
212+
} else {
213+
if _, err := fmt.Fprint(c.writer, "Select one option (label or number). Optional free text: other: <text>\n> "); err != nil {
214+
return nil, err
215+
}
216+
}
217+
218+
line, err := c.reader.ReadString('\n')
219+
if err != nil {
220+
return nil, err
221+
}
222+
223+
answer, err := parseAnswer(strings.TrimSpace(line), question)
224+
if err != nil {
225+
return nil, err
226+
}
227+
228+
answers[question.Header] = answer
229+
}
230+
231+
return answers, nil
232+
}
233+
234+
func parseAnswer(raw string, question human.Question) (human.Answer, error) {
235+
result := human.Answer{}
236+
if raw == "" {
237+
return result, nil
238+
}
239+
240+
parts := strings.Split(raw, ",")
241+
seen := map[string]struct{}{}
242+
243+
for _, part := range parts {
244+
choice := strings.TrimSpace(part)
245+
if choice == "" {
246+
continue
247+
}
248+
249+
if strings.HasPrefix(strings.ToLower(choice), "other:") {
250+
result.Other = strings.TrimSpace(choice[len("other:"):])
251+
continue
252+
}
253+
254+
label, err := resolveOptionLabel(choice, question.Options)
255+
if err != nil {
256+
return human.Answer{}, err
257+
}
258+
259+
normalized := strings.ToLower(label)
260+
if _, exists := seen[normalized]; exists {
261+
continue
262+
}
263+
seen[normalized] = struct{}{}
264+
result.Selections = append(result.Selections, label)
265+
}
266+
267+
if !question.MultiSelect && len(result.Selections) > 1 {
268+
return human.Answer{}, fmt.Errorf("question %q allows only one selection", question.Header)
269+
}
270+
271+
return result, nil
272+
}
273+
274+
func resolveOptionLabel(choice string, options []human.Choice) (string, error) {
275+
index, err := strconv.Atoi(choice)
276+
if err == nil {
277+
if index < 1 || index > len(options) {
278+
return "", fmt.Errorf("invalid option index: %d", index)
279+
}
280+
return options[index-1].Label, nil
281+
}
282+
283+
normalizedChoice := strings.ToLower(strings.TrimSpace(choice))
284+
for _, option := range options {
285+
if strings.ToLower(option.Label) == normalizedChoice {
286+
return option.Label, nil
287+
}
288+
}
289+
290+
return "", fmt.Errorf("invalid option label: %q", choice)
291+
}

examples/skills/README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
This example demonstrates how to:
44

55
- discover and parse local `SKILL.md` definitions
6-
- turn each skill into a callable tool for an `agent.Agent`
6+
- expose a single dispatcher tool for skill execution
77
- let the agent combine a skill output with a file-writing tool
88

99
It includes one skill: `get-random-quote`, which fetches a quote from https://zenquotes.io by running a small Go script.
@@ -58,9 +58,8 @@ and a file named `quote.html` will be created in the current directory.
5858

5959
## What it does
6060

61-
- Creates a skills parser rooted at `./skills`.
62-
- Lists skill directories (each contains a `SKILL.md`).
63-
- Parses each `SKILL.md` and converts it into a tool via `skill.AsTool(...)`.
61+
- Creates a skill dispatcher rooted at `./skills`.
62+
- Exposes one `skill` tool via `tool/skill` and lets the agent choose a skill by name at runtime.
6463
- Adds an explicit file write tool (with an interactive validation prompt).
6564
- Runs the agent with: "create a web page containing a random quote, and save the html to a file called quote.html".
6665

0 commit comments

Comments
 (0)