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
14 changes: 14 additions & 0 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,20 @@ forge serve logs

The daemon forks `forge run` in the background with `setsid`, writes state to `.forge/serve.json`, and redirects output to `.forge/serve.log`. Passphrase prompting for encrypted secrets happens in the parent process (which has TTY access) before forking.

## File Output Directory

The runtime configures a `FilesDir` for tool-generated files (e.g., from `file_create`). This directory defaults to `<WorkDir>/.forge/files/` and is injected into the execution context so tools can write files that other tools can reference by path.

```
<WorkDir>/
.forge/
files/ ← file_create output (patches.yaml, reports, etc.)
sessions/ ← conversation persistence
memory/ ← long-term memory
```

The `FilesDir` is set via `LLMExecutorConfig.FilesDir` and made available to tools through `runtime.FilesDirFromContext(ctx)`. See [Tools — File Create](tools.md#file-create) for details.

## Conversation Memory

For details on session persistence, context window management, compaction, and long-term memory, see [Memory](memory.md).
Expand Down
79 changes: 63 additions & 16 deletions docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ Skills are defined in a Markdown file (default: `SKILL.md`). The file supports o
```markdown
---
name: weather
icon: 🌤️
category: utilities
tags:
- weather
- forecast
- api
description: Weather data skill
metadata:
forge:
Expand Down Expand Up @@ -45,13 +51,24 @@ Each `## Tool:` heading defines a tool the agent can call. The frontmatter decla

### YAML Frontmatter

The `metadata.forge.requires` block declares:
Top-level fields:

| Field | Required | Description |
|-------|----------|-------------|
| `name` | yes | Skill identifier (kebab-case) |
| `icon` | yes | Emoji displayed in the TUI skill picker |
| `category` | yes | Grouping for `forge skills list --category` (e.g., `sre`, `developer`, `research`, `utilities`) |
| `tags` | yes | Discovery keywords for `forge skills list --tags` (kebab-case) |
| `description` | yes | One-line summary |

The `metadata.forge.requires` block declares runtime dependencies:

- **`bins`** — Binary dependencies that must be in `$PATH` at runtime
- **`env.required`** — Environment variables that must be set
- **`env.one_of`** — At least one of these environment variables must be set
- **`env.optional`** — Optional environment variables for extended functionality

Frontmatter is parsed by `ParseWithMetadata()` in `forge-core/skills/parser.go` and feeds into the compilation pipeline.
Frontmatter is parsed by `ParseWithMetadata()` in `forge-skills/parser/parser.go` and feeds into the compilation pipeline.

### Legacy List Format

Expand Down Expand Up @@ -118,11 +135,12 @@ Skill scripts run in a restricted environment via `SkillCommandExecutor`:

## Skill Categories & Tags

Skills can declare a `category` and `tags` in their frontmatter for organization and filtering:
All embedded skills must declare `category`, `tags`, and `icon` in their frontmatter. Categories and tags must be lowercase kebab-case.

```markdown
---
name: k8s-incident-triage
icon: ☸️
category: sre
tags:
- kubernetes
Expand All @@ -131,7 +149,7 @@ tags:
---
```

Categories and tags must be lowercase kebab-case. Use them to filter skills:
Use categories and tags to filter skills:

```bash
# List skills by category
Expand All @@ -143,18 +161,19 @@ forge skills list --tags kubernetes,incident-response

## Built-in Skills

| Skill | Category | Description | Scripts |
|-------|----------|-------------|---------|
| `github` | — | Create issues, PRs, and query repositories | — (binary-backed) |
| `weather` | — | Get weather data for a location | — (binary-backed) |
| `tavily-search` | — | Search the web using Tavily AI search API | `tavily-search.sh` |
| `tavily-research` | — | Deep multi-source research via Tavily API | `tavily-research.sh`, `tavily-research-poll.sh` |
| `k8s-incident-triage` | sre | Read-only Kubernetes incident triage using kubectl | — (binary-backed) |
| `code-review` | developer | AI-powered code review for diffs and files | `code-review-diff.sh`, `code-review-file.sh` |
| `code-review-standards` | developer | Initialize and manage code review standards | — (template-based) |
| `code-review-github` | developer | Post code review results to GitHub PRs | — (binary-backed) |
| `codegen-react` | developer | Scaffold and iterate on Vite + React apps | `codegen-react-scaffold.sh`, `codegen-react-read.sh`, `codegen-react-write.sh`, `codegen-react-run.sh` |
| `codegen-html` | developer | Scaffold standalone Preact + HTM apps (zero dependencies) | `codegen-html-scaffold.sh`, `codegen-html-read.sh`, `codegen-html-write.sh` |
| Skill | Icon | Category | Description | Scripts |
|-------|------|----------|-------------|---------|
| `github` | 🐙 | developer | Create issues, PRs, and query repositories | — (binary-backed) |
| `weather` | 🌤️ | utilities | Get weather data for a location | — (binary-backed) |
| `tavily-search` | 🔍 | research | Search the web using Tavily AI search API | `tavily-search.sh` |
| `tavily-research` | 🔬 | research | Deep multi-source research via Tavily API | `tavily-research.sh`, `tavily-research-poll.sh` |
| `k8s-incident-triage` | ☸️ | sre | Read-only Kubernetes incident triage using kubectl | — (binary-backed) |
| `k8s-pod-rightsizer` | ⚖️ | sre | Analyze workload metrics and produce CPU/memory rightsizing recommendations | — (binary-backed) |
| `code-review` | 🔎 | developer | AI-powered code review for diffs and files | `code-review-diff.sh`, `code-review-file.sh` |
| `code-review-standards` | 📏 | developer | Initialize and manage code review standards | — (template-based) |
| `code-review-github` | 🐙 | developer | Post code review results to GitHub PRs | — (binary-backed) |
| `codegen-react` | ⚛️ | developer | Scaffold and iterate on Vite + React apps | `codegen-react-scaffold.sh`, `codegen-react-read.sh`, `codegen-react-write.sh`, `codegen-react-run.sh` |
| `codegen-html` | 🌐 | developer | Scaffold standalone Preact + HTM apps (zero dependencies) | `codegen-html-scaffold.sh`, `codegen-html-read.sh`, `codegen-html-write.sh` |

### Tavily Research Skill

Expand Down Expand Up @@ -218,6 +237,34 @@ The skill accepts two input modes:

Requires: `kubectl`, optional `KUBECONFIG`, `K8S_API_DOMAIN`, `DEFAULT_NAMESPACE` environment variables.

### Kubernetes Pod Rightsizer Skill

The `k8s-pod-rightsizer` skill analyzes real workload metrics (Prometheus or metrics-server fallback) and produces policy-constrained CPU/memory rightsizing recommendations:

```bash
forge skills add k8s-pod-rightsizer
```

This skill operates in three modes:

| Mode | Purpose | Mutates Cluster |
|------|---------|-----------------|
| `dry-run` | Report recommendations only (default) | No |
| `plan` | Generate strategic merge patch YAMLs | No |
| `apply` | Execute patches with rollback bundle | Yes (requires `i_accept_risk: true`) |

**Key features:**

- Deterministic formulas — no LLM-based guessing for recommendations
- Policy model with per-namespace and per-workload overrides (safety factors, min/max bounds, step constraints)
- Prometheus p95 metrics with metrics-server fallback
- Automatic rollback bundle generation in apply mode
- Workload classification: over-provisioned, under-provisioned, right-sized, limit-bound, insufficient-data

**Apply workflow:** The skill's built-in `mode=apply` handles rollback bundles, strategic merge patches via `kubectl patch`, and rollout verification. Do not manually run `kubectl apply -f` — use `mode=apply` with `i_accept_risk: true` instead.

Requires: `bash`, `kubectl`, `jq`, `curl`. Optional: `KUBECONFIG`, `K8S_API_DOMAIN`, `PROMETHEUS_URL`, `PROMETHEUS_TOKEN`, `POLICY_FILE`, `DEFAULT_NAMESPACE`.

### Codegen React Skill

The `codegen-react` skill scaffolds and iterates on **Vite + React** applications with Tailwind CSS:
Expand Down
31 changes: 31 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Tools are capabilities that an LLM agent can invoke during execution. Forge prov
| `uuid_generate` | Generate UUID v4 identifiers |
| `math_calculate` | Evaluate mathematical expressions |
| `web_search` | Search the web for quick lookups and recent information |
| `file_create` | Create a downloadable file, written to the agent's `.forge/files/` directory |
| `read_skill` | Load full instructions for an available skill on demand |
| `memory_search` | Search long-term memory (when enabled) |
| `memory_get` | Read memory files (when enabled) |
Expand Down Expand Up @@ -80,6 +81,36 @@ tools:
| 6 | **Environment isolation** | Only `PATH`, `HOME`, `LANG`, explicit passthrough vars, and proxy vars |
| 7 | **Output limits** | Configurable max output size (default: 1MB) to prevent memory exhaustion |

## File Create

The `file_create` tool generates downloadable files that are both written to disk and uploaded to the user's channel (Slack/Telegram).

| Field | Description |
|-------|-------------|
| `filename` | Name with extension (e.g., `patches.yaml`, `report.json`) |
| `content` | Full file content as text |

**Output JSON** includes `filename`, `content`, `mime_type`, and `path`. The `path` field contains the absolute disk location, allowing other tools (e.g., `kubectl apply -f <path>`) to reference the file.

**File location:** Files are written to the agent's `.forge/files/` directory (under `WorkDir`). The runtime injects this path via `FilesDir` in the executor context. When running outside the full runtime (e.g., tests), falls back to `$TMPDIR/forge-files/`.

**Allowed extensions:**

| Extension | MIME Type |
|-----------|-----------|
| `.md` | `text/markdown` |
| `.json` | `application/json` |
| `.yaml`, `.yml` | `text/yaml` |
| `.txt`, `.log` | `text/plain` |
| `.csv` | `text/csv` |
| `.sh` | `text/x-shellscript` |
| `.xml` | `text/xml` |
| `.html` | `text/html` |
| `.py` | `text/x-python` |
| `.ts` | `text/typescript` |

Filenames with path separators (`/`, `\`) or traversal patterns (`..`) are rejected.

## Memory Tools

When [long-term memory](memory.md) is enabled, two additional tools are registered:
Expand Down
1 change: 1 addition & 0 deletions forge-cli/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ func collectInteractive(opts *initOptions) error {
Name: s.Name,
DisplayName: s.DisplayName,
Description: s.Description,
Icon: s.Icon,
RequiredEnv: s.RequiredEnv,
OneOfEnv: s.OneOfEnv,
OptionalEnv: s.OptionalEnv,
Expand Down
21 changes: 9 additions & 12 deletions forge-cli/internal/tui/steps/skills_step.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type SkillInfo struct {
Name string
DisplayName string
Description string
Icon string
RequiredEnv []string
OneOfEnv []string
OptionalEnv []string
Expand Down Expand Up @@ -70,7 +71,10 @@ func NewSkillsStep(styles *tui.StyleSet, skills []SkillInfo) *SkillsStep {

var items []components.MultiSelectItem
for _, sk := range skills {
icon := skillIcon(sk.Name)
icon := sk.Icon
if icon == "" {
icon = skillIcon(sk.Name)
}
var reqs []string
if len(sk.RequiredBins) > 0 {
reqs = append(reqs, "bins: "+strings.Join(sk.RequiredBins, ", "))
Expand Down Expand Up @@ -411,16 +415,9 @@ func (s *SkillsStep) Apply(ctx *tui.WizardContext) {
}
}

func skillIcon(name string) string {
icons := map[string]string{
"github": "🐙",
"weather": "🌤️",
"tavily-search": "🔍",
"k8s-incident-triage": "☸️",
"k8s_incident_triage": "☸️",
}
if icon, ok := icons[name]; ok {
return icon
}
// skillIcon returns a default icon for skills that don't declare one
// in their SKILL.md frontmatter. Prefer adding "icon:" to frontmatter
// instead of extending this function.
func skillIcon(_ string) string {
return "📦"
}
1 change: 1 addition & 0 deletions forge-cli/runtime/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ func (r *Runner) Run(ctx context.Context) error {
Logger: r.logger,
ModelName: mc.Client.Model,
CharBudget: charBudget,
FilesDir: filepath.Join(r.cfg.WorkDir, ".forge", "files"),
}

// Initialize memory persistence (enabled by default).
Expand Down
17 changes: 16 additions & 1 deletion forge-core/runtime/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ func (a *AuditLogger) Emit(event AuditEvent) {
a.mu.Unlock()
}

// Context key types for correlation and task IDs.
// Context key types for correlation IDs, task IDs, and file directories.
type correlationIDKey struct{}
type taskIDKey struct{}
type filesDirKey struct{}

// WithCorrelationID stores a correlation ID in the context.
func WithCorrelationID(ctx context.Context, id string) context.Context {
Expand Down Expand Up @@ -96,6 +97,20 @@ func TaskIDFromContext(ctx context.Context) string {
return ""
}

// WithFilesDir stores a files directory path in the context.
func WithFilesDir(ctx context.Context, dir string) context.Context {
return context.WithValue(ctx, filesDirKey{}, dir)
}

// FilesDirFromContext retrieves the files directory from the context.
// Returns "" if not set.
func FilesDirFromContext(ctx context.Context) string {
if dir, ok := ctx.Value(filesDirKey{}).(string); ok {
return dir
}
return ""
}

// GenerateID produces a 16-character hex random ID using crypto/rand.
func GenerateID() string {
b := make([]byte, 8)
Expand Down
50 changes: 46 additions & 4 deletions forge-core/runtime/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type LLMExecutor struct {
modelName string // resolved model name for context budget
charBudget int // resolved character budget
maxToolResultChars int // computed from char budget
filesDir string // directory for file_create output
}

// LLMExecutorConfig configures the LLM executor.
Expand All @@ -45,6 +46,7 @@ type LLMExecutorConfig struct {
Logger Logger
ModelName string // model name for context-aware budgeting
CharBudget int // explicit char budget override (0 = auto from model)
FilesDir string // directory for file_create output (default: $TMPDIR/forge-files)
}

// NewLLMExecutor creates a new LLMExecutor with the given configuration.
Expand Down Expand Up @@ -93,11 +95,16 @@ func NewLLMExecutor(cfg LLMExecutorConfig) *LLMExecutor {
modelName: cfg.ModelName,
charBudget: budget,
maxToolResultChars: toolLimit,
filesDir: cfg.FilesDir,
}
}

// Execute processes a message through the LLM agent loop.
func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Message) (*a2a.Message, error) {
if e.filesDir != "" {
ctx = WithFilesDir(ctx, e.filesDir)
}

mem := NewMemory(e.systemPrompt, e.charBudget, e.modelName)

// Try to recover session from disk. If found, the disk snapshot
Expand Down Expand Up @@ -239,13 +246,31 @@ func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Mess
return nil, fmt.Errorf("after tool exec hook: %w", err)
}

// Track large tool outputs for pass-through in the response.
if len(result) > largeToolOutputThreshold {
// Handle file_create tool: always create a file part.
// For other tools with large output, detect content type.
if tc.Function.Name == "file_create" {
var fc struct {
Filename string `json:"filename"`
Content string `json:"content"`
MimeType string `json:"mime_type"`
}
if err := json.Unmarshal([]byte(result), &fc); err == nil && fc.Filename != "" {
largeToolOutputs = append(largeToolOutputs, a2a.Part{
Kind: a2a.PartKindFile,
File: &a2a.FileContent{
Name: fc.Filename,
MimeType: fc.MimeType,
Bytes: []byte(fc.Content),
},
})
}
} else if len(result) > largeToolOutputThreshold {
name, mime := detectFileType(result, tc.Function.Name)
largeToolOutputs = append(largeToolOutputs, a2a.Part{
Kind: a2a.PartKindFile,
File: &a2a.FileContent{
Name: tc.Function.Name + "-output.md",
MimeType: "text/markdown",
Name: name,
MimeType: mime,
Bytes: []byte(result),
},
})
Expand Down Expand Up @@ -327,6 +352,23 @@ func a2aMessageToLLM(msg a2a.Message) llm.ChatMessage {
}
}

// detectFileType inspects tool output content and returns an appropriate
// filename and MIME type. JSON and YAML content gets typed extensions;
// everything else defaults to markdown.
func detectFileType(content, toolName string) (filename, mimeType string) {
trimmed := strings.TrimSpace(content)
if len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') {
// Quick check: try to parse as JSON.
if json.Valid([]byte(trimmed)) {
return toolName + "-output.json", "application/json"
}
}
if strings.HasPrefix(trimmed, "---") {
return toolName + "-output.yaml", "text/yaml"
}
return toolName + "-output.md", "text/markdown"
}

// llmMessageToA2A converts an LLM chat message to an A2A message.
// Any extra parts (e.g. large tool output files) are appended after the text part.
func llmMessageToA2A(msg llm.ChatMessage, extraParts ...a2a.Part) *a2a.Message {
Expand Down
Loading
Loading