Skip to content

Commit 90a3bf0

Browse files
authored
Merge pull request #21 from initializ/skills/k8s-podrightsize
feat: add k8s-pod-rightsizer skill, file_create tool, and skill metadata improvements
2 parents 2da95ab + 14b5066 commit 90a3bf0

28 files changed

Lines changed: 2182 additions & 36 deletions

File tree

docs/runtime.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,20 @@ forge serve logs
180180

181181
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.
182182

183+
## File Output Directory
184+
185+
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.
186+
187+
```
188+
<WorkDir>/
189+
.forge/
190+
files/ ← file_create output (patches.yaml, reports, etc.)
191+
sessions/ ← conversation persistence
192+
memory/ ← long-term memory
193+
```
194+
195+
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.
196+
183197
## Conversation Memory
184198

185199
For details on session persistence, context window management, compaction, and long-term memory, see [Memory](memory.md).

docs/skills.md

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ Skills are defined in a Markdown file (default: `SKILL.md`). The file supports o
1515
```markdown
1616
---
1717
name: weather
18+
icon: 🌤️
19+
category: utilities
20+
tags:
21+
- weather
22+
- forecast
23+
- api
1824
description: Weather data skill
1925
metadata:
2026
forge:
@@ -45,13 +51,24 @@ Each `## Tool:` heading defines a tool the agent can call. The frontmatter decla
4551

4652
### YAML Frontmatter
4753

48-
The `metadata.forge.requires` block declares:
54+
Top-level fields:
55+
56+
| Field | Required | Description |
57+
|-------|----------|-------------|
58+
| `name` | yes | Skill identifier (kebab-case) |
59+
| `icon` | yes | Emoji displayed in the TUI skill picker |
60+
| `category` | yes | Grouping for `forge skills list --category` (e.g., `sre`, `developer`, `research`, `utilities`) |
61+
| `tags` | yes | Discovery keywords for `forge skills list --tags` (kebab-case) |
62+
| `description` | yes | One-line summary |
63+
64+
The `metadata.forge.requires` block declares runtime dependencies:
65+
4966
- **`bins`** — Binary dependencies that must be in `$PATH` at runtime
5067
- **`env.required`** — Environment variables that must be set
5168
- **`env.one_of`** — At least one of these environment variables must be set
5269
- **`env.optional`** — Optional environment variables for extended functionality
5370

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

5673
### Legacy List Format
5774

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

119136
## Skill Categories & Tags
120137

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

123140
```markdown
124141
---
125142
name: k8s-incident-triage
143+
icon: ☸️
126144
category: sre
127145
tags:
128146
- kubernetes
@@ -131,7 +149,7 @@ tags:
131149
---
132150
```
133151

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

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

144162
## Built-in Skills
145163

146-
| Skill | Category | Description | Scripts |
147-
|-------|----------|-------------|---------|
148-
| `github` || Create issues, PRs, and query repositories | — (binary-backed) |
149-
| `weather` || Get weather data for a location | — (binary-backed) |
150-
| `tavily-search` || Search the web using Tavily AI search API | `tavily-search.sh` |
151-
| `tavily-research` || Deep multi-source research via Tavily API | `tavily-research.sh`, `tavily-research-poll.sh` |
152-
| `k8s-incident-triage` | sre | Read-only Kubernetes incident triage using kubectl | — (binary-backed) |
153-
| `code-review` | developer | AI-powered code review for diffs and files | `code-review-diff.sh`, `code-review-file.sh` |
154-
| `code-review-standards` | developer | Initialize and manage code review standards | — (template-based) |
155-
| `code-review-github` | developer | Post code review results to GitHub PRs | — (binary-backed) |
156-
| `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` |
157-
| `codegen-html` | developer | Scaffold standalone Preact + HTM apps (zero dependencies) | `codegen-html-scaffold.sh`, `codegen-html-read.sh`, `codegen-html-write.sh` |
164+
| Skill | Icon | Category | Description | Scripts |
165+
|-------|------|----------|-------------|---------|
166+
| `github` | 🐙 | developer | Create issues, PRs, and query repositories | — (binary-backed) |
167+
| `weather` | 🌤️ | utilities | Get weather data for a location | — (binary-backed) |
168+
| `tavily-search` | 🔍 | research | Search the web using Tavily AI search API | `tavily-search.sh` |
169+
| `tavily-research` | 🔬 | research | Deep multi-source research via Tavily API | `tavily-research.sh`, `tavily-research-poll.sh` |
170+
| `k8s-incident-triage` | ☸️ | sre | Read-only Kubernetes incident triage using kubectl | — (binary-backed) |
171+
| `k8s-pod-rightsizer` | ⚖️ | sre | Analyze workload metrics and produce CPU/memory rightsizing recommendations | — (binary-backed) |
172+
| `code-review` | 🔎 | developer | AI-powered code review for diffs and files | `code-review-diff.sh`, `code-review-file.sh` |
173+
| `code-review-standards` | 📏 | developer | Initialize and manage code review standards | — (template-based) |
174+
| `code-review-github` | 🐙 | developer | Post code review results to GitHub PRs | — (binary-backed) |
175+
| `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` |
176+
| `codegen-html` | 🌐 | developer | Scaffold standalone Preact + HTM apps (zero dependencies) | `codegen-html-scaffold.sh`, `codegen-html-read.sh`, `codegen-html-write.sh` |
158177

159178
### Tavily Research Skill
160179

@@ -218,6 +237,34 @@ The skill accepts two input modes:
218237

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

240+
### Kubernetes Pod Rightsizer Skill
241+
242+
The `k8s-pod-rightsizer` skill analyzes real workload metrics (Prometheus or metrics-server fallback) and produces policy-constrained CPU/memory rightsizing recommendations:
243+
244+
```bash
245+
forge skills add k8s-pod-rightsizer
246+
```
247+
248+
This skill operates in three modes:
249+
250+
| Mode | Purpose | Mutates Cluster |
251+
|------|---------|-----------------|
252+
| `dry-run` | Report recommendations only (default) | No |
253+
| `plan` | Generate strategic merge patch YAMLs | No |
254+
| `apply` | Execute patches with rollback bundle | Yes (requires `i_accept_risk: true`) |
255+
256+
**Key features:**
257+
258+
- Deterministic formulas — no LLM-based guessing for recommendations
259+
- Policy model with per-namespace and per-workload overrides (safety factors, min/max bounds, step constraints)
260+
- Prometheus p95 metrics with metrics-server fallback
261+
- Automatic rollback bundle generation in apply mode
262+
- Workload classification: over-provisioned, under-provisioned, right-sized, limit-bound, insufficient-data
263+
264+
**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.
265+
266+
Requires: `bash`, `kubectl`, `jq`, `curl`. Optional: `KUBECONFIG`, `K8S_API_DOMAIN`, `PROMETHEUS_URL`, `PROMETHEUS_TOKEN`, `POLICY_FILE`, `DEFAULT_NAMESPACE`.
267+
221268
### Codegen React Skill
222269

223270
The `codegen-react` skill scaffolds and iterates on **Vite + React** applications with Tailwind CSS:

docs/tools.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Tools are capabilities that an LLM agent can invoke during execution. Forge prov
2424
| `uuid_generate` | Generate UUID v4 identifiers |
2525
| `math_calculate` | Evaluate mathematical expressions |
2626
| `web_search` | Search the web for quick lookups and recent information |
27+
| `file_create` | Create a downloadable file, written to the agent's `.forge/files/` directory |
2728
| `read_skill` | Load full instructions for an available skill on demand |
2829
| `memory_search` | Search long-term memory (when enabled) |
2930
| `memory_get` | Read memory files (when enabled) |
@@ -80,6 +81,36 @@ tools:
8081
| 6 | **Environment isolation** | Only `PATH`, `HOME`, `LANG`, explicit passthrough vars, proxy vars, and `OPENAI_ORG_ID` (when set) |
8182
| 7 | **Output limits** | Configurable max output size (default: 1MB) to prevent memory exhaustion |
8283

84+
## File Create
85+
86+
The `file_create` tool generates downloadable files that are both written to disk and uploaded to the user's channel (Slack/Telegram).
87+
88+
| Field | Description |
89+
|-------|-------------|
90+
| `filename` | Name with extension (e.g., `patches.yaml`, `report.json`) |
91+
| `content` | Full file content as text |
92+
93+
**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.
94+
95+
**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/`.
96+
97+
**Allowed extensions:**
98+
99+
| Extension | MIME Type |
100+
|-----------|-----------|
101+
| `.md` | `text/markdown` |
102+
| `.json` | `application/json` |
103+
| `.yaml`, `.yml` | `text/yaml` |
104+
| `.txt`, `.log` | `text/plain` |
105+
| `.csv` | `text/csv` |
106+
| `.sh` | `text/x-shellscript` |
107+
| `.xml` | `text/xml` |
108+
| `.html` | `text/html` |
109+
| `.py` | `text/x-python` |
110+
| `.ts` | `text/typescript` |
111+
112+
Filenames with path separators (`/`, `\`) or traversal patterns (`..`) are rejected.
113+
83114
## Memory Tools
84115

85116
When [long-term memory](memory.md) is enabled, two additional tools are registered:

forge-cli/cmd/init.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ func collectInteractive(opts *initOptions) error {
211211
Name: s.Name,
212212
DisplayName: s.DisplayName,
213213
Description: s.Description,
214+
Icon: s.Icon,
214215
RequiredEnv: s.RequiredEnv,
215216
OneOfEnv: s.OneOfEnv,
216217
OptionalEnv: s.OptionalEnv,

forge-cli/internal/tui/steps/skills_step.go

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type SkillInfo struct {
1616
Name string
1717
DisplayName string
1818
Description string
19+
Icon string
1920
RequiredEnv []string
2021
OneOfEnv []string
2122
OptionalEnv []string
@@ -70,7 +71,10 @@ func NewSkillsStep(styles *tui.StyleSet, skills []SkillInfo) *SkillsStep {
7071

7172
var items []components.MultiSelectItem
7273
for _, sk := range skills {
73-
icon := skillIcon(sk.Name)
74+
icon := sk.Icon
75+
if icon == "" {
76+
icon = skillIcon(sk.Name)
77+
}
7478
var reqs []string
7579
if len(sk.RequiredBins) > 0 {
7680
reqs = append(reqs, "bins: "+strings.Join(sk.RequiredBins, ", "))
@@ -417,16 +421,9 @@ func (s *SkillsStep) Apply(ctx *tui.WizardContext) {
417421
}
418422
}
419423

420-
func skillIcon(name string) string {
421-
icons := map[string]string{
422-
"github": "🐙",
423-
"weather": "🌤️",
424-
"tavily-search": "🔍",
425-
"k8s-incident-triage": "☸️",
426-
"k8s_incident_triage": "☸️",
427-
}
428-
if icon, ok := icons[name]; ok {
429-
return icon
430-
}
424+
// skillIcon returns a default icon for skills that don't declare one
425+
// in their SKILL.md frontmatter. Prefer adding "icon:" to frontmatter
426+
// instead of extending this function.
427+
func skillIcon(_ string) string {
431428
return "📦"
432429
}

forge-cli/runtime/runner.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ func (r *Runner) Run(ctx context.Context) error {
387387
Logger: r.logger,
388388
ModelName: mc.Client.Model,
389389
CharBudget: charBudget,
390+
FilesDir: filepath.Join(r.cfg.WorkDir, ".forge", "files"),
390391
}
391392

392393
// Initialize memory persistence (enabled by default).

forge-core/runtime/audit.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ func (a *AuditLogger) Emit(event AuditEvent) {
6464
a.mu.Unlock()
6565
}
6666

67-
// Context key types for correlation and task IDs.
67+
// Context key types for correlation IDs, task IDs, and file directories.
6868
type correlationIDKey struct{}
6969
type taskIDKey struct{}
70+
type filesDirKey struct{}
7071

7172
// WithCorrelationID stores a correlation ID in the context.
7273
func WithCorrelationID(ctx context.Context, id string) context.Context {
@@ -96,6 +97,20 @@ func TaskIDFromContext(ctx context.Context) string {
9697
return ""
9798
}
9899

100+
// WithFilesDir stores a files directory path in the context.
101+
func WithFilesDir(ctx context.Context, dir string) context.Context {
102+
return context.WithValue(ctx, filesDirKey{}, dir)
103+
}
104+
105+
// FilesDirFromContext retrieves the files directory from the context.
106+
// Returns "" if not set.
107+
func FilesDirFromContext(ctx context.Context) string {
108+
if dir, ok := ctx.Value(filesDirKey{}).(string); ok {
109+
return dir
110+
}
111+
return ""
112+
}
113+
99114
// GenerateID produces a 16-character hex random ID using crypto/rand.
100115
func GenerateID() string {
101116
b := make([]byte, 8)

forge-core/runtime/loop.go

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type LLMExecutor struct {
3131
modelName string // resolved model name for context budget
3232
charBudget int // resolved character budget
3333
maxToolResultChars int // computed from char budget
34+
filesDir string // directory for file_create output
3435
}
3536

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

5052
// NewLLMExecutor creates a new LLMExecutor with the given configuration.
@@ -93,11 +95,16 @@ func NewLLMExecutor(cfg LLMExecutorConfig) *LLMExecutor {
9395
modelName: cfg.ModelName,
9496
charBudget: budget,
9597
maxToolResultChars: toolLimit,
98+
filesDir: cfg.FilesDir,
9699
}
97100
}
98101

99102
// Execute processes a message through the LLM agent loop.
100103
func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Message) (*a2a.Message, error) {
104+
if e.filesDir != "" {
105+
ctx = WithFilesDir(ctx, e.filesDir)
106+
}
107+
101108
mem := NewMemory(e.systemPrompt, e.charBudget, e.modelName)
102109

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

242-
// Track large tool outputs for pass-through in the response.
243-
if len(result) > largeToolOutputThreshold {
249+
// Handle file_create tool: always create a file part.
250+
// For other tools with large output, detect content type.
251+
if tc.Function.Name == "file_create" {
252+
var fc struct {
253+
Filename string `json:"filename"`
254+
Content string `json:"content"`
255+
MimeType string `json:"mime_type"`
256+
}
257+
if err := json.Unmarshal([]byte(result), &fc); err == nil && fc.Filename != "" {
258+
largeToolOutputs = append(largeToolOutputs, a2a.Part{
259+
Kind: a2a.PartKindFile,
260+
File: &a2a.FileContent{
261+
Name: fc.Filename,
262+
MimeType: fc.MimeType,
263+
Bytes: []byte(fc.Content),
264+
},
265+
})
266+
}
267+
} else if len(result) > largeToolOutputThreshold {
268+
name, mime := detectFileType(result, tc.Function.Name)
244269
largeToolOutputs = append(largeToolOutputs, a2a.Part{
245270
Kind: a2a.PartKindFile,
246271
File: &a2a.FileContent{
247-
Name: tc.Function.Name + "-output.md",
248-
MimeType: "text/markdown",
272+
Name: name,
273+
MimeType: mime,
249274
Bytes: []byte(result),
250275
},
251276
})
@@ -327,6 +352,23 @@ func a2aMessageToLLM(msg a2a.Message) llm.ChatMessage {
327352
}
328353
}
329354

355+
// detectFileType inspects tool output content and returns an appropriate
356+
// filename and MIME type. JSON and YAML content gets typed extensions;
357+
// everything else defaults to markdown.
358+
func detectFileType(content, toolName string) (filename, mimeType string) {
359+
trimmed := strings.TrimSpace(content)
360+
if len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') {
361+
// Quick check: try to parse as JSON.
362+
if json.Valid([]byte(trimmed)) {
363+
return toolName + "-output.json", "application/json"
364+
}
365+
}
366+
if strings.HasPrefix(trimmed, "---") {
367+
return toolName + "-output.yaml", "text/yaml"
368+
}
369+
return toolName + "-output.md", "text/markdown"
370+
}
371+
330372
// llmMessageToA2A converts an LLM chat message to an A2A message.
331373
// Any extra parts (e.g. large tool output files) are appended after the text part.
332374
func llmMessageToA2A(msg llm.ChatMessage, extraParts ...a2a.Part) *a2a.Message {

0 commit comments

Comments
 (0)