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
8 changes: 8 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ forge init [name] [flags]
| `--tools` | | | Builtin tools to enable (e.g., `web_search,http_request`) |
| `--skills` | | | Registry skills to include (e.g., `github,weather`) |
| `--api-key` | | | LLM provider API key |
| `--org-id` | | | OpenAI Organization ID (enterprise) |
| `--from-skills` | | | Path to a SKILL.md file for auto-configuration |
| `--non-interactive` | | `false` | Skip interactive prompts |

Expand Down Expand Up @@ -62,6 +63,13 @@ forge init my-agent \
--skills github \
--api-key sk-... \
--non-interactive

# OpenAI enterprise with organization ID
forge init my-agent \
--model-provider openai \
--api-key sk-... \
--org-id org-xxxxxxxxxxxxxxxxxxxxxxxx \
--non-interactive
```

---
Expand Down
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ entrypoint: "agent.py" # Required for crewai/langchain, omit for fo
model:
provider: "openai" # openai, anthropic, gemini, ollama, custom
name: "gpt-4o" # Model name
organization_id: "org-xxx" # OpenAI Organization ID (enterprise, optional)
fallbacks: # Fallback providers (optional)
- provider: "anthropic"
name: "claude-sonnet-4-20250514"
organization_id: "" # Per-fallback org ID override (optional)

tools:
- name: "web_search"
Expand Down Expand Up @@ -80,6 +82,7 @@ schedules: # Recurring scheduled tasks (optional)
| `FORGE_MEMORY_LONG_TERM` | Set `true` to enable long-term memory |
| `FORGE_EMBEDDING_PROVIDER` | Override embedding provider |
| `OPENAI_API_KEY` | OpenAI API key |
| `OPENAI_ORG_ID` | OpenAI Organization ID (enterprise); overrides `organization_id` in YAML |
| `ANTHROPIC_API_KEY` | Anthropic API key |
| `GEMINI_API_KEY` | Google Gemini API key |
| `TAVILY_API_KEY` | Tavily web search API key |
Expand Down
2 changes: 1 addition & 1 deletion docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ A multi-step wizard (web equivalent of `forge init`) that walks through the full
|------|-------------|
| Name | Set agent name with live slug preview |
| Provider | Select LLM provider (OpenAI, Anthropic, Gemini, Ollama, Custom) with descriptions |
| Model & Auth | Pick from provider-specific model lists; OpenAI supports API key or browser OAuth login |
| Model & Auth | Pick from provider-specific model lists; OpenAI supports API key or browser OAuth login, plus optional Organization ID for enterprise accounts |
| Channels | Select Slack/Telegram with inline token collection |
| Tools | Select builtin tools; web_search shows Tavily vs Perplexity provider choice with API key input |
| Skills | Browse registry skills by category with inline required/optional env var collection |
Expand Down
14 changes: 14 additions & 0 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ hooks.Register(engine.BeforeToolExec, func(ctx context.Context, hctx *engine.Hoo
})
```

## Audit Logging

The runner registers `AfterLLMCall` hooks that emit structured audit events for each LLM interaction. Audit fields include:

| Field | Description |
|-------|-------------|
| `provider` | LLM provider name |
| `model` | Model identifier |
| `input_tokens` | Prompt token count |
| `output_tokens` | Completion token count |
| `organization_id` | OpenAI Organization ID (when set) |

These events are logged via `slog` at Info level and can be consumed by external log aggregators for cost tracking and compliance.

## Progress Tracking

The runner automatically registers progress hooks that emit real-time status updates during tool execution. Progress events include the tool name, phase (`tool_start` / `tool_end`), and a human-readable status message. These events are streamed to clients via SSE when using the A2A HTTP server, enabling live progress indicators in web and chat UIs.
Expand Down
21 changes: 20 additions & 1 deletion docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Forge supports multiple LLM providers with automatic fallback:

| Provider | Default Model | Auth |
|----------|--------------|------|
| `openai` | `gpt-5.2-2025-12-11` | API key or OAuth |
| `openai` | `gpt-5.2-2025-12-11` | API key or OAuth; optional Organization ID |
| `anthropic` | `claude-sonnet-4-20250514` | API key |
| `gemini` | `gemini-2.5-flash` | API key |
| `ollama` | `llama3` | None (local) |
Expand Down Expand Up @@ -67,6 +67,25 @@ forge init my-agent

OAuth tokens are stored in `~/.forge/credentials/openai.json` and automatically refreshed.

### Organization ID (OpenAI Enterprise)

Enterprise OpenAI accounts can set an Organization ID to route API requests to the correct org:

```yaml
model:
provider: openai
name: gpt-4o
organization_id: "org-xxxxxxxxxxxxxxxxxxxxxxxx"
```

Or via environment variable (overrides YAML):

```bash
export OPENAI_ORG_ID=org-xxxxxxxxxxxxxxxxxxxxxxxx
```

The `OpenAI-Organization` header is sent on all OpenAI API requests (chat, embeddings, responses). Fallback providers inherit the primary org ID unless overridden per-fallback. The org ID is also injected into skill subprocess environments as `OPENAI_ORG_ID`.

### Fallback Chains

Configure fallback providers for automatic failover when the primary provider is unavailable:
Expand Down
2 changes: 1 addition & 1 deletion docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ tools:
| 3 | **Argument validation** | Rejects arguments containing `$(`, backticks, or newlines |
| 4 | **Timeout** | Configurable per-command timeout (default: 120s) |
| 5 | **No shell** | Uses `exec.CommandContext` directly — no shell expansion |
| 6 | **Environment isolation** | Only `PATH`, `HOME`, `LANG`, explicit passthrough vars, and proxy vars |
| 6 | **Environment isolation** | Only `PATH`, `HOME`, `LANG`, explicit passthrough vars, proxy vars, and `OPENAI_ORG_ID` (when set) |
| 7 | **Output limits** | Configurable max output size (default: 1MB) to prevent memory exhaustion |

## Memory Tools
Expand Down
55 changes: 32 additions & 23 deletions forge-cli/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type initOptions struct {
Language string
ModelProvider string
APIKey string // validated provider key
OrganizationID string // OpenAI enterprise organization ID
Fallbacks []tui.FallbackProvider
Channels []string
SkillsFile string
Expand All @@ -54,21 +55,22 @@ type toolEntry struct {

// templateData is passed to all templates during rendering.
type templateData struct {
Name string
AgentID string
Framework string
Language string
Entrypoint string
ModelProvider string
ModelName string
Fallbacks []fallbackTmplData
Channels []string
Tools []toolEntry
BuiltinTools []string
SkillEntries []skillTmplData
EgressDomains []string
EnvVars []envVarEntry
HasSecrets bool
Name string
AgentID string
Framework string
Language string
Entrypoint string
ModelProvider string
ModelName string
OrganizationID string
Fallbacks []fallbackTmplData
Channels []string
Tools []toolEntry
BuiltinTools []string
SkillEntries []skillTmplData
EgressDomains []string
EnvVars []envVarEntry
HasSecrets bool
}

// fallbackTmplData holds template data for a fallback provider.
Expand Down Expand Up @@ -116,6 +118,7 @@ func init() {
initCmd.Flags().StringSlice("tools", nil, "builtin tools to enable (e.g., web_search,http_request)")
initCmd.Flags().StringSlice("skills", nil, "registry skills to include (e.g., github,weather)")
initCmd.Flags().String("api-key", "", "LLM provider API key")
initCmd.Flags().String("org-id", "", "OpenAI organization ID (enterprise)")
initCmd.Flags().StringSlice("fallbacks", nil, "fallback LLM providers (e.g., openai,gemini)")
initCmd.Flags().Bool("force", false, "overwrite existing directory")
}
Expand All @@ -142,6 +145,7 @@ func runInit(cmd *cobra.Command, args []string) error {
opts.BuiltinTools, _ = cmd.Flags().GetStringSlice("tools")
opts.Skills, _ = cmd.Flags().GetStringSlice("skills")
opts.APIKey, _ = cmd.Flags().GetString("api-key")
opts.OrganizationID, _ = cmd.Flags().GetString("org-id")
fallbackProviders, _ := cmd.Flags().GetStringSlice("fallbacks")
for _, p := range fallbackProviders {
opts.Fallbacks = append(opts.Fallbacks, tui.FallbackProvider{Provider: p})
Expand Down Expand Up @@ -286,6 +290,7 @@ func collectInteractive(opts *initOptions) error {
opts.ModelProvider = ctx.Provider
opts.APIKey = ctx.APIKey
opts.AuthMethod = ctx.AuthMethod
opts.OrganizationID = ctx.OrganizationID
opts.Fallbacks = ctx.Fallbacks
opts.CustomModel = ctx.CustomModel
// Use wizard-selected model name if available
Expand Down Expand Up @@ -928,14 +933,15 @@ func getFileManifest(opts *initOptions) []fileToRender {

func buildTemplateData(opts *initOptions) templateData {
data := templateData{
Name: opts.Name,
AgentID: opts.AgentID,
Framework: opts.Framework,
Language: opts.Language,
ModelProvider: opts.ModelProvider,
Channels: opts.Channels,
Tools: opts.Tools,
BuiltinTools: opts.BuiltinTools,
Name: opts.Name,
AgentID: opts.AgentID,
Framework: opts.Framework,
Language: opts.Language,
ModelProvider: opts.ModelProvider,
OrganizationID: opts.OrganizationID,
Channels: opts.Channels,
Tools: opts.Tools,
BuiltinTools: opts.BuiltinTools,
}

// Set entrypoint based on framework (only for subprocess-based frameworks)
Expand Down Expand Up @@ -1033,6 +1039,9 @@ func buildEnvVars(opts *initOptions) []envVarEntry {
val = "your-api-key-here"
}
vars = append(vars, envVarEntry{Key: "OPENAI_API_KEY", Value: val, Comment: "OpenAI API key"})
if orgID := opts.OrganizationID; orgID != "" {
vars = append(vars, envVarEntry{Key: "OPENAI_ORG_ID", Value: orgID, Comment: "OpenAI organization ID (enterprise)"})
}
case "anthropic":
val := opts.EnvVars["ANTHROPIC_API_KEY"]
if val == "" {
Expand Down
6 changes: 6 additions & 0 deletions forge-cli/cmd/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ func runUI(cmd *cobra.Command, args []string) error {
CustomModel: opts.ModelName,
APIKey: opts.APIKey,
AuthMethod: opts.AuthMethod,
OrganizationID: opts.OrganizationID,
Fallbacks: fallbacks,
Channels: opts.Channels,
BuiltinTools: opts.BuiltinTools,
Expand All @@ -136,6 +137,11 @@ func runUI(cmd *cobra.Command, args []string) error {
initOpts.EnvVars["WEB_SEARCH_PROVIDER"] = opts.WebSearchProvider
}

// Store organization ID for OpenAI enterprise
if opts.OrganizationID != "" {
initOpts.EnvVars["OPENAI_ORG_ID"] = opts.OrganizationID
}

// Set passphrase for secret encryption if provided
if opts.Passphrase != "" {
_ = os.Setenv("FORGE_PASSPHRASE", opts.Passphrase)
Expand Down
72 changes: 66 additions & 6 deletions forge-cli/internal/tui/components/multi_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ type MultiSelectItem struct {
type MultiSelect struct {
Items []MultiSelectItem
cursor int
offset int // index of first visible item
height int // terminal height (0 = no constraint)
done bool

// Styles
Expand Down Expand Up @@ -59,21 +61,59 @@ func (m *MultiSelect) Init() tea.Cmd {
return nil
}

// maxVisibleItems returns how many items fit in the viewport.
func (m MultiSelect) maxVisibleItems() int {
if m.height <= 0 || len(m.Items) == 0 {
return len(m.Items)
}
// Each item ≈ 4 lines (border top, content, border bottom, gap).
// Reserve ~18 lines for wizard chrome (banner, progress, kbd hints, padding).
available := (m.height - 18) / 4
if available < 3 {
available = 3
}
if available >= len(m.Items) {
return len(m.Items)
}
return available
}

// adjustOffset ensures the cursor is within the visible window.
func (m *MultiSelect) adjustOffset() {
maxVisible := m.maxVisibleItems()
if m.cursor < m.offset {
m.offset = m.cursor
}
if m.cursor >= m.offset+maxVisible {
m.offset = m.cursor - maxVisible + 1
}
if m.offset < 0 {
m.offset = 0
}
}

// Update handles keyboard input.
func (m MultiSelect) Update(msg tea.Msg) (MultiSelect, tea.Cmd) {
if m.done {
return m, nil
}

if msg, ok := msg.(tea.KeyMsg); ok {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.height = msg.Height
m.adjustOffset()
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
if m.cursor > 0 {
m.cursor--
m.adjustOffset()
}
case "down", "j":
if m.cursor < len(m.Items)-1 {
m.cursor++
m.adjustOffset()
}
case " ":
m.Items[m.cursor].Checked = !m.Items[m.cursor].Checked
Expand All @@ -99,14 +139,28 @@ func (m MultiSelect) Update(msg tea.Msg) (MultiSelect, tea.Cmd) {

// View renders the multi-select list.
func (m MultiSelect) View(width int) string {
var out string
var b strings.Builder

itemWidth := width - 6
if itemWidth < 30 {
itemWidth = 30
}

for i, item := range m.Items {
maxVisible := m.maxVisibleItems()
start := m.offset
end := start + maxVisible
if end > len(m.Items) {
end = len(m.Items)
}

// Scroll indicator: items above
if start > 0 {
hint := fmt.Sprintf(" ▲ %d more above", start)
b.WriteString(lipgloss.NewStyle().Foreground(m.DimColor).Render(hint) + "\n")
}

for i := start; i < end; i++ {
item := m.Items[i]
isCursor := i == m.cursor
var checkbox, icon, label, desc string

Expand Down Expand Up @@ -148,11 +202,17 @@ func (m MultiSelect) View(width int) string {
border = m.InactiveBorder.Width(itemWidth)
}

out += " " + border.Render(content) + "\n"
b.WriteString(" " + border.Render(content) + "\n")
}

// Scroll indicator: items below
if end < len(m.Items) {
hint := fmt.Sprintf(" ▼ %d more below", len(m.Items)-end)
b.WriteString(lipgloss.NewStyle().Foreground(m.DimColor).Render(hint) + "\n")
}

out += "\n" + m.kbd.View()
return out
b.WriteString("\n" + m.kbd.View())
return b.String()
}

// Done returns true when selection is confirmed.
Expand Down
Loading
Loading