From 3edc391b69391086e810a93cba343d1541745979 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 20 Jul 2026 19:56:09 -0400 Subject: [PATCH 01/13] =?UTF-8?q?feat(cli):=20forge=20try=20=E2=80=94=20Ph?= =?UTF-8?q?ase=201=20skeleton=20+=20demo=20preset=20+=20ephemeral=20scaffo?= =?UTF-8?q?ld=20(#350)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `forge try` command that scaffolds a keyless demo agent (the native forge LLM executor + weather skill + http_request/datetime_now/math_calculate builtins) and prints its summary. Ephemeral temp workspace by default, cleaned on exit; --keep writes ./forge-quickstart. Reuses scaffold() via two additions to initOptions (no duplication): - OutputDir overrides the target dir (temp dir / ./forge-quickstart) - Preset stops scaffold() before its banner + auto-run of `forge run`, so try drives the agent in-process (Phases 2-4). Egress auto-derives from the weather skill (allowlist, wttr.in — keyless; depends on #351). No channels, no secrets on disk. No LLM call yet. Verified: ephemeral scaffold + summary + cleanup (no cwd leak); --keep writes a valid forge.yaml; init suite green; gofmt/vet/lint clean. --- forge-cli/cmd/init.go | 18 +++++ forge-cli/cmd/root.go | 1 + forge-cli/cmd/try.go | 158 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 forge-cli/cmd/try.go diff --git a/forge-cli/cmd/init.go b/forge-cli/cmd/init.go index 5500ce0..bd857bd 100644 --- a/forge-cli/cmd/init.go +++ b/forge-cli/cmd/init.go @@ -47,6 +47,15 @@ type initOptions struct { AuthMethod string // "apikey" or "oauth" Compression bool // reversible context compression (ctxzip) — compression.enabled in forge.yaml + // OutputDir overrides the scaffold target directory. Empty falls back + // to ./ (the classic `forge init` layout). `forge try` sets + // this to an ephemeral temp dir (or ./forge-quickstart under --keep). + OutputDir string + // Preset marks a non-wizard scaffold (e.g. `forge try`). It stops + // scaffold() before the "Created …" banner and the auto-run of + // `forge run` — the caller drives the agent itself, in-process. + Preset bool + // A2A auth chain (from the wizard's Authentication step or CLI flags). AuthMode string // "", "none", "oidc", "http_verifier", "custom" AuthSettings map[string]any // provider-specific settings block @@ -695,6 +704,9 @@ func scaffold(opts *initOptions) error { normalizeCustomProvider(opts) dir := filepath.Join(".", opts.AgentID) + if opts.OutputDir != "" { + dir = opts.OutputDir + } // Check if directory already exists if !opts.Force { @@ -815,6 +827,12 @@ func scaffold(opts *initOptions) error { } } + // Preset scaffolds (e.g. `forge try`) drive the agent in-process; stop + // before the banner and the auto-run of `forge run` below. + if opts.Preset { + return nil + } + fmt.Printf("\nCreated agent project in ./%s\n", opts.AgentID) // Show channel-specific reminders diff --git a/forge-cli/cmd/root.go b/forge-cli/cmd/root.go index 568082f..83f612c 100644 --- a/forge-cli/cmd/root.go +++ b/forge-cli/cmd/root.go @@ -31,6 +31,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&themeOverride, "theme", "", "TUI color theme: dark, light, or auto") rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(tryCmd) rootCmd.AddCommand(validateCmd) rootCmd.AddCommand(buildCmd) rootCmd.AddCommand(runCmd) diff --git a/forge-cli/cmd/try.go b/forge-cli/cmd/try.go new file mode 100644 index 0000000..730c162 --- /dev/null +++ b/forge-cli/cmd/try.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/initializ/forge/forge-cli/config" + "github.com/initializ/forge/forge-core/types" + "github.com/spf13/cobra" +) + +// forge try — the instant-gratification onboarding command. It scaffolds a +// keyless demo agent into a throwaway workspace, resolves whatever model +// credential is already available, and drops the user into a streaming chat +// whose tool calls and egress checks render inline. No build, no channels, no +// server, no secrets on disk. See issue #350. +var tryCmd = &cobra.Command{ + Use: "try [prompt]", + Short: "Talk to a live agent in your terminal — no build, no cluster", + Long: "Scaffold a keyless demo agent and chat with it in your terminal, watching every " + + "tool call and egress check as it runs. The workspace is ephemeral unless you pass " + + "--keep, which writes it to ./forge-quickstart so you can make it your own.", + Args: cobra.MaximumNArgs(1), + RunE: runTry, +} + +// tryFlags holds the parsed command flags for one `forge try` invocation. +type tryFlags struct { + keep bool + provider string + model string + once string + onceSet bool // whether --once was explicitly set (distinguishes "" from unset) + quiet bool + audit bool + yes bool + prompt string // optional positional first prompt +} + +func init() { + tryCmd.Flags().Bool("keep", false, "keep the demo agent in ./forge-quickstart instead of a temp dir") + tryCmd.Flags().String("provider", "", "model provider: openai, anthropic, gemini, or ollama") + tryCmd.Flags().String("model", "", "model name (defaults to the provider's default)") + tryCmd.Flags().String("once", "", "run a single prompt non-interactively, then exit") + tryCmd.Flags().Bool("quiet", false, "hide the inline tool/egress loop lines") + tryCmd.Flags().Bool("audit", false, "show the full NDJSON audit event stream") + tryCmd.Flags().Bool("yes", false, "assume yes to prompts (non-interactive)") +} + +func parseTryFlags(cmd *cobra.Command, args []string) tryFlags { + f := tryFlags{} + f.keep, _ = cmd.Flags().GetBool("keep") + f.provider, _ = cmd.Flags().GetString("provider") + f.model, _ = cmd.Flags().GetString("model") + f.once, _ = cmd.Flags().GetString("once") + f.onceSet = cmd.Flags().Changed("once") + f.quiet, _ = cmd.Flags().GetBool("quiet") + f.audit, _ = cmd.Flags().GetBool("audit") + f.yes, _ = cmd.Flags().GetBool("yes") + if len(args) > 0 { + f.prompt = args[0] + } + return f +} + +func runTry(cmd *cobra.Command, args []string) error { + flags := parseTryFlags(cmd, args) + + // Phase 2 replaces this with resolveTryProvider (env key → saved OAuth → + // Ollama → interactive picker). For now the provider is explicit. + if flags.provider == "" { + return fmt.Errorf("specify --provider (openai, anthropic, gemini, or ollama); " + + "automatic credential resolution lands in a later build") + } + + dir, cleanup, err := tryWorkspace(flags.keep) + if err != nil { + return err + } + if cleanup != nil { + defer cleanup() + } + + opts := quickstartPreset(flags.provider, flags.model) + opts.OutputDir = dir + opts.Force = true // forge owns this dir (a temp dir, or ./forge-quickstart) + + if err := scaffold(opts); err != nil { + return err + } + + cfg, err := config.LoadForgeConfig(filepath.Join(dir, "forge.yaml")) + if err != nil { + return fmt.Errorf("loading scaffolded agent: %w", err) + } + + printTrySummary(cmd, cfg, opts) + + // Phase 3 drives the REPL / --once turn here. Phase 1 stops after the + // agent loads and its summary prints. + if flags.keep { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n Kept the demo agent in ./forge-quickstart\n") + } + return nil +} + +// tryWorkspace returns the scaffold target directory, plus a cleanup func for +// the ephemeral case (nil under --keep). The ephemeral agent lives in a leaf +// under a fresh temp dir so scaffold()'s not-exists check still holds, and the +// whole temp tree is removed on exit — no secrets ever persist. +func tryWorkspace(keep bool) (dir string, cleanup func(), err error) { + if keep { + return filepath.Join(".", "forge-quickstart"), nil, nil + } + base, err := os.MkdirTemp("", "forge-try-") + if err != nil { + return "", nil, fmt.Errorf("creating temp workspace: %w", err) + } + return filepath.Join(base, "quickstart"), func() { _ = os.RemoveAll(base) }, nil +} + +// quickstartPreset is the demo agent: the native forge LLM executor, the +// keyless weather skill, and a few safe builtins. Egress is auto-derived from +// the skill (allowlist, no dev-open); no channels, no long-term memory, no +// secrets. Preset stops scaffold() before its auto-run so `forge try` drives +// the loop in-process. +func quickstartPreset(provider, model string) *initOptions { + return &initOptions{ + Name: "quickstart", + AgentID: "quickstart", + Framework: "forge", + ModelProvider: provider, + CustomModel: model, // empty → provider default (buildTemplateData) + AuthMethod: "apikey", + BuiltinTools: []string{"http_request", "datetime_now", "math_calculate"}, + Skills: []string{"weather"}, + NonInteractive: true, + Preset: true, + EnvVars: map[string]string{}, + } +} + +// printTrySummary prints the one-line agent summary shown at startup, e.g. +// +// Agent: quickstart · skills: weather · tools: http_request, datetime_now, math_calculate +func printTrySummary(cmd *cobra.Command, cfg *types.ForgeConfig, opts *initOptions) { + out := cmd.OutOrStdout() + parts := []string{fmt.Sprintf("Agent: %s", cfg.AgentID)} + if len(opts.Skills) > 0 { + parts = append(parts, "skills: "+strings.Join(opts.Skills, ", ")) + } + if len(opts.BuiltinTools) > 0 { + parts = append(parts, "tools: "+strings.Join(opts.BuiltinTools, ", ")) + } + _, _ = fmt.Fprintf(out, " %s\n", strings.Join(parts, " · ")) +} From 8f0dd025065c78f6b15280cda17d20c6849f38fe Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 20 Jul 2026 20:00:15 -0400 Subject: [PATCH 02/13] =?UTF-8?q?feat(cli):=20forge=20try=20=E2=80=94=20Ph?= =?UTF-8?q?ase=202=20model=20credential=20auto-resolution=20(#350)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveTryProvider layers an ordering policy over the runner's existing resolution: explicit flags -> env key (ANTHROPIC > OPENAI > GEMINI) -> saved OpenAI OAuth -> local Ollama (short-dial probe) -> interactive picker (TTY) -> actionable error (no TTY). Prints a human 'Using ...' label. Picker offers OpenAI sign-in (runOAuthFlow), paste-key (masked, in-memory), or Ollama. No new credential store; SilenceUsage so the no-credential error stays clean. Verified: env-key path silent + correct label with the documented precedence; no-creds/no-TTY yields the actionable error and exit 1; explicit flags still win. gofmt/vet/lint clean. --- forge-cli/cmd/try.go | 198 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 190 insertions(+), 8 deletions(-) diff --git a/forge-cli/cmd/try.go b/forge-cli/cmd/try.go index 730c162..ea77d01 100644 --- a/forge-cli/cmd/try.go +++ b/forge-cli/cmd/try.go @@ -1,14 +1,22 @@ package cmd import ( + "bufio" + "context" + "errors" "fmt" + "io" + "net" "os" "path/filepath" "strings" + "time" "github.com/initializ/forge/forge-cli/config" + "github.com/initializ/forge/forge-core/llm/oauth" "github.com/initializ/forge/forge-core/types" "github.com/spf13/cobra" + "golang.org/x/term" ) // forge try — the instant-gratification onboarding command. It scaffolds a @@ -22,8 +30,9 @@ var tryCmd = &cobra.Command{ Long: "Scaffold a keyless demo agent and chat with it in your terminal, watching every " + "tool call and egress check as it runs. The workspace is ephemeral unless you pass " + "--keep, which writes it to ./forge-quickstart so you can make it your own.", - Args: cobra.MaximumNArgs(1), - RunE: runTry, + Args: cobra.MaximumNArgs(1), + RunE: runTry, + SilenceUsage: true, // runtime errors (e.g. no credential) shouldn't dump flag help } // tryFlags holds the parsed command flags for one `forge try` invocation. @@ -67,13 +76,14 @@ func parseTryFlags(cmd *cobra.Command, args []string) tryFlags { func runTry(cmd *cobra.Command, args []string) error { flags := parseTryFlags(cmd, args) + out := cmd.OutOrStdout() - // Phase 2 replaces this with resolveTryProvider (env key → saved OAuth → - // Ollama → interactive picker). For now the provider is explicit. - if flags.provider == "" { - return fmt.Errorf("specify --provider (openai, anthropic, gemini, or ollama); " + - "automatic credential resolution lands in a later build") + interactive := term.IsTerminal(int(os.Stdin.Fd())) + res, err := resolveTryProvider(cmd.Context(), flags, os.Stdin, out, interactive) + if err != nil { + return err } + _, _ = fmt.Fprintf(out, " %s\n", res.Label) dir, cleanup, err := tryWorkspace(flags.keep) if err != nil { @@ -83,7 +93,7 @@ func runTry(cmd *cobra.Command, args []string) error { defer cleanup() } - opts := quickstartPreset(flags.provider, flags.model) + opts := quickstartPreset(res.Provider, res.Model) opts.OutputDir = dir opts.Force = true // forge owns this dir (a temp dir, or ./forge-quickstart) @@ -156,3 +166,175 @@ func printTrySummary(cmd *cobra.Command, cfg *types.ForgeConfig, opts *initOptio } _, _ = fmt.Fprintf(out, " %s\n", strings.Join(parts, " · ")) } + +// tryResolution is the outcome of credential auto-resolution: which provider +// and model to run, a human label for the startup summary, and any credential +// env vars to inject for the in-process run (paste-key picker path only). +type tryResolution struct { + Provider string + Model string + Label string + EnvOverrides map[string]string +} + +// errNoTryCredential is returned when no model credential is available and the +// session is non-interactive (CI), so no picker can be shown. +var errNoTryCredential = errors.New( + "no model credential found. Do one of:\n" + + " - set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY in your environment\n" + + " - run `forge try` in an interactive terminal to sign in with OpenAI\n" + + " - start a local model with Ollama (https://ollama.com), then `ollama serve`\n" + + "or pass --provider and --model explicitly") + +// resolveTryProvider picks the fastest available model credential with no +// prompts when one exists, and offers a minimal picker only when nothing is. +// Ordering: explicit flags -> env key -> saved OpenAI OAuth -> local Ollama -> +// interactive picker (TTY) -> actionable error (no TTY). It does not add a new +// credential store; it layers an ordering policy over the runner's existing +// resolution. +func resolveTryProvider(ctx context.Context, flags tryFlags, in io.Reader, out io.Writer, interactive bool) (tryResolution, error) { + // 1. Explicit flags win. + if flags.provider != "" { + model := modelOrDefault(flags.provider, flags.model) + return tryResolution{ + Provider: flags.provider, + Model: model, + Label: fmt.Sprintf("Using %s (%s).", flags.provider, model), + }, nil + } + + // 2. Env key, preferring Anthropic, then OpenAI, then Gemini. + for _, e := range []struct{ env, provider string }{ + {"ANTHROPIC_API_KEY", "anthropic"}, + {"OPENAI_API_KEY", "openai"}, + {"GEMINI_API_KEY", "gemini"}, + } { + if os.Getenv(e.env) != "" { + return tryResolution{ + Provider: e.provider, + Model: modelOrDefault(e.provider, flags.model), + Label: fmt.Sprintf("Using %s from env.", e.env), + }, nil + } + } + + // 3. Saved OpenAI OAuth session (Responses endpoint). + if tok, err := oauth.LoadCredentials("openai"); err == nil && tok != nil && tok.RefreshToken != "" { + return tryResolution{ + Provider: "openai", + Model: modelOrDefault("openai", flags.model), + Label: "Using OpenAI (signed in).", + }, nil + } + + // 4. Local Ollama daemon. + if ollamaReachable(ctx) { + model := modelOrDefault("ollama", flags.model) + return tryResolution{ + Provider: "ollama", + Model: model, + Label: fmt.Sprintf("Using local Ollama (%s). First response may be slow if the model must download.", model), + }, nil + } + + // 5/6. Nothing available. + if !interactive { + return tryResolution{}, errNoTryCredential + } + return tryPicker(flags, in, out) +} + +// modelOrDefault returns the explicit model if set, else the provider default. +func modelOrDefault(provider, model string) string { + if model != "" { + return model + } + return defaultModelNameForProvider(provider) +} + +// ollamaReachable reports whether a local Ollama daemon answers on its default +// port (or OLLAMA_HOST/OLLAMA_BASE_URL host:port). A short dial keeps startup +// snappy when Ollama is absent. +func ollamaReachable(ctx context.Context) bool { + addr := "127.0.0.1:11434" + if h := os.Getenv("OLLAMA_HOST"); h != "" { + addr = strings.TrimPrefix(strings.TrimPrefix(h, "http://"), "https://") + } + dctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond) + defer cancel() + conn, err := (&net.Dialer{}).DialContext(dctx, "tcp", addr) + if err != nil { + return false + } + _ = conn.Close() + return true +} + +// tryPicker is the fallback shown only when nothing is auto-detected and a TTY +// is present: sign in with OpenAI, paste a key, or use local Ollama. +func tryPicker(flags tryFlags, in io.Reader, out io.Writer) (tryResolution, error) { + _, _ = fmt.Fprintln(out, "\n No model credential found. How would you like to connect?") + _, _ = fmt.Fprintln(out, " 1) Sign in with OpenAI (recommended)") + _, _ = fmt.Fprintln(out, " 2) Paste an API key") + _, _ = fmt.Fprintln(out, " 3) Use local Ollama") + _, _ = fmt.Fprint(out, " > ") + + choice, _ := bufio.NewReader(in).ReadString('\n') + switch strings.TrimSpace(choice) { + case "1", "": + if _, err := runOAuthFlow("openai"); err != nil { + return tryResolution{}, fmt.Errorf("OpenAI sign-in: %w", err) + } + return tryResolution{ + Provider: "openai", + Model: modelOrDefault("openai", flags.model), + Label: "Using OpenAI (signed in).", + }, nil + case "2": + return pasteKeyResolution(flags, out) + case "3": + model := modelOrDefault("ollama", flags.model) + return tryResolution{ + Provider: "ollama", + Model: model, + Label: fmt.Sprintf("Using local Ollama (%s).", model), + }, nil + default: + return tryResolution{}, fmt.Errorf("unrecognized choice %q", strings.TrimSpace(choice)) + } +} + +// pasteKeyResolution prompts for a provider and a masked API key, held only in +// memory (written to disk solely under --keep, via the scaffold env path). +func pasteKeyResolution(flags tryFlags, out io.Writer) (tryResolution, error) { + _, _ = fmt.Fprint(out, " Provider (openai / anthropic / gemini): ") + prov, _ := bufio.NewReader(os.Stdin).ReadString('\n') + provider := strings.ToLower(strings.TrimSpace(prov)) + keyEnv, ok := providerKeyEnv[provider] + if !ok { + return tryResolution{}, fmt.Errorf("unsupported provider %q for a pasted key", provider) + } + _, _ = fmt.Fprint(out, " API key: ") + raw, err := term.ReadPassword(int(os.Stdin.Fd())) + _, _ = fmt.Fprintln(out) + if err != nil { + return tryResolution{}, fmt.Errorf("reading key: %w", err) + } + key := strings.TrimSpace(string(raw)) + if key == "" { + return tryResolution{}, fmt.Errorf("empty API key") + } + return tryResolution{ + Provider: provider, + Model: modelOrDefault(provider, flags.model), + Label: fmt.Sprintf("Using %s (pasted key).", provider), + EnvOverrides: map[string]string{keyEnv: key}, + }, nil +} + +// providerKeyEnv maps a provider to its API-key environment variable. +var providerKeyEnv = map[string]string{ + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "gemini": "GEMINI_API_KEY", +} From 91705d03b3d1022dde965ac892fcb17f8332e37b Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 20 Jul 2026 20:12:59 -0400 Subject: [PATCH 03/13] =?UTF-8?q?feat(cli):=20forge=20try=20=E2=80=94=20Ph?= =?UTF-8?q?ase=203=20in-process=20REPL=20driving=20the=20executor=20(#350)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New forge-cli/runtime/LocalSession assembles the SAME coreruntime.LLMExecutor that forge run uses (builtin tools + vendored skills, egress-enforced client + subprocess proxy, audit + progress hooks, provider client) WITHOUT an HTTP server, scheduler, MCP, or long-term memory. No second executor — a trimmed bootstrap around the shared sub-builders. History rides in task.History; the executor Store is nil so nothing persists. try.go drives it: RunTurn per line, streaming the reply under 'agent ›', /exit + Ctrl-D + Ctrl-C (signal.NotifyContext) exit, --once single turn, an optional positional prompt seeds the first turn. The runner's JSON logger is silenced so stdout stays clean for the chat. Verified: real turn via a mock OpenAI server (llm_call fires, reply returned, history accumulates to 2 then 4 across turns); session construction (egress proxy + skill registration + client build) succeeds live; runtime + cmd suites green; gofmt/vet/lint clean. --- forge-cli/cmd/try.go | 82 ++++++- forge-cli/runtime/local_session.go | 276 ++++++++++++++++++++++++ forge-cli/runtime/local_session_test.go | 59 +++++ 3 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 forge-cli/runtime/local_session.go create mode 100644 forge-cli/runtime/local_session_test.go diff --git a/forge-cli/cmd/try.go b/forge-cli/cmd/try.go index ea77d01..0f35297 100644 --- a/forge-cli/cmd/try.go +++ b/forge-cli/cmd/try.go @@ -8,11 +8,13 @@ import ( "io" "net" "os" + "os/signal" "path/filepath" "strings" "time" "github.com/initializ/forge/forge-cli/config" + "github.com/initializ/forge/forge-cli/runtime" "github.com/initializ/forge/forge-core/llm/oauth" "github.com/initializ/forge/forge-core/types" "github.com/spf13/cobra" @@ -108,10 +110,84 @@ func runTry(cmd *cobra.Command, args []string) error { printTrySummary(cmd, cfg, opts) - // Phase 3 drives the REPL / --once turn here. Phase 1 stops after the - // agent loads and its summary prints. + sess, err := runtime.NewLocalSession(cmd.Context(), runtime.LocalSessionOptions{ + Config: cfg, + WorkDir: dir, + EnvOverrides: res.EnvOverrides, + Verbose: false, + }) + if err != nil { + return err + } + defer func() { _ = sess.Close() }() + + // Ctrl-C cancels the in-flight turn (executor cancels at the next + // iteration / tool boundary) and ends the loop. + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer stop() + + // --once: run a single turn (or, for an empty prompt, just load + exit). + if flags.onceSet { + if strings.TrimSpace(flags.once) != "" { + if err := tryOneTurn(ctx, sess, out, flags.once); err != nil { + return err + } + } + } else { + if err := tryREPL(ctx, sess, cmd, flags); err != nil { + return err + } + } + if flags.keep { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n Kept the demo agent in ./forge-quickstart\n") + _, _ = fmt.Fprintf(out, "\n Kept the demo agent in ./forge-quickstart\n") + } + return nil +} + +// tryOneTurn runs a single non-interactive turn and prints the reply. +func tryOneTurn(ctx context.Context, sess *runtime.LocalSession, out io.Writer, prompt string) error { + reply, err := sess.RunTurn(ctx, prompt, nil) // Phase 4 wires the progress emitter + if err != nil { + return err + } + _, _ = fmt.Fprintf(out, "\nagent › %s\n", reply) + return nil +} + +// tryREPL reads one prompt per line and runs a turn each, keeping history in +// the session. It exits on /exit, /quit, Ctrl-D (EOF), or a cancelled context. +// An optional positional prompt seeds the first turn. +func tryREPL(ctx context.Context, sess *runtime.LocalSession, cmd *cobra.Command, flags tryFlags) error { + out := cmd.OutOrStdout() + scanner := bufio.NewScanner(cmd.InOrStdin()) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + pending := strings.TrimSpace(flags.prompt) + for { + if pending == "" { + _, _ = fmt.Fprint(out, "\nyou › ") + if !scanner.Scan() { + break // Ctrl-D / EOF + } + pending = strings.TrimSpace(scanner.Text()) + } + if pending == "" { + continue + } + if pending == "/exit" || pending == "/quit" { + break + } + reply, err := sess.RunTurn(ctx, pending, nil) // Phase 4 wires the progress emitter + pending = "" + if err != nil { + if ctx.Err() != nil { + break // cancelled mid-turn + } + _, _ = fmt.Fprintf(out, "\n error: %v\n", err) + continue + } + _, _ = fmt.Fprintf(out, "\nagent › %s\n", reply) } return nil } diff --git a/forge-cli/runtime/local_session.go b/forge-cli/runtime/local_session.go new file mode 100644 index 0000000..16809eb --- /dev/null +++ b/forge-cli/runtime/local_session.go @@ -0,0 +1,276 @@ +package runtime + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/observability" + coreruntime "github.com/initializ/forge/forge-core/runtime" + "github.com/initializ/forge/forge-core/security" + "github.com/initializ/forge/forge-core/tools" + "github.com/initializ/forge/forge-core/tools/builtins" + "github.com/initializ/forge/forge-core/types" +) + +// LocalSession is an in-process agent runtime for `forge try` (issue #350). It +// assembles the SAME coreruntime.LLMExecutor that `forge run` uses — the +// built-in tool registry, egress enforcement (in-process client + subprocess +// proxy), audit + progress hooks, and provider client — but WITHOUT an HTTP +// server, scheduler, MCP, admission, auth, or long-term memory. There is no +// second executor: this is a trimmed bootstrap around the shared sub-builders. +// +// Turns run one at a time via RunTurn. Conversation history is kept in memory +// and never persisted (the executor Store is nil), so nothing touches disk for +// the ephemeral run. +type LocalSession struct { + runner *Runner + executor coreruntime.AgentExecutor + audit *coreruntime.AuditLogger + egressClient *http.Client + proxyStop func() + history []a2a.Message + taskID string +} + +// LocalSessionOptions configure an in-process `forge try` session. +type LocalSessionOptions struct { + Config *types.ForgeConfig + WorkDir string + EnvOverrides map[string]string // credential env from the paste-key picker (else nil) + Verbose bool +} + +// NewLocalSession builds the in-process executor for the demo agent. The order +// mirrors Run(): resolve env → egress → tools → model → hooks → executor. +func NewLocalSession(ctx context.Context, opts LocalSessionOptions) (*LocalSession, error) { + if opts.Config == nil { + return nil, fmt.Errorf("config is required") + } + r, err := NewRunner(RunnerConfig{ + Config: opts.Config, + WorkDir: opts.WorkDir, + Host: "127.0.0.1", + Verbose: opts.Verbose, + }) + if err != nil { + return nil, err + } + // Silence the runner's structured logger: `forge try` keeps stdout clean + // for the chat + the visible-loop renderer (which reads the audit stream, + // not this logger). Real failures surface as returned errors. + if !opts.Verbose { + r.logger = coreruntime.NewJSONLogger(io.Discard, false) + } + + // envVars: process env, overlaid with the agent's .env, overlaid with any + // paste-key credentials. The runner's Run() reads only .env; `forge try` + // also honors credentials already in the environment. + envVars := osEnvMap() + fileEnv, _ := LoadEnvFile(filepath.Join(opts.WorkDir, ".env")) + for k, v := range fileEnv { + envVars[k] = v + } + for k, v := range opts.EnvOverrides { + envVars[k] = v + _ = os.Setenv(k, v) + } + + audit := coreruntime.NewAuditLoggerFromConfig(r.cfg.AuditExport) + + // Resolve the model first — registerSkillTools reads r.modelConfig. + mc := coreruntime.ResolveModelConfig(opts.Config, envVars, r.cfg.ProviderOverride) + if mc == nil { + return nil, fmt.Errorf("no model provider could be resolved for the demo agent") + } + r.modelConfig = mc + + // Egress: in-process enforced client (for builtin http tools) + a local + // proxy for subprocess skills. Both emit egress_allowed / egress_blocked. + egressClient, proxyURL, proxyStop := r.buildTryEgress(ctx, envVars, audit) + + // Tool registry: builtins + the vendored skills (subprocess via proxy). + reg := tools.NewRegistry() + if err := builtins.RegisterAll(reg, builtins.Options{}); err != nil { + proxyStop() + return nil, fmt.Errorf("registering builtin tools: %w", err) + } + r.registerSkillTools(reg, proxyURL) + + llmClient, err := r.buildLLMClient(mc) + if err != nil { + proxyStop() + return nil, fmt.Errorf("building model client: %w", err) + } + + hooks := coreruntime.NewHookRegistry() + r.registerAuditHooks(hooks, audit) + r.registerProgressHooks(hooks) + + executor := coreruntime.NewLLMExecutor(coreruntime.LLMExecutorConfig{ + Client: llmClient, + Tools: reg, + Hooks: hooks, + SystemPrompt: r.buildSystemPrompt(), + ModelName: mc.Client.Model, + Provider: mc.Provider, + // Store is nil: history rides in task.History, nothing persists. + }) + + return &LocalSession{ + runner: r, + executor: executor, + audit: audit, + egressClient: egressClient, + proxyStop: proxyStop, + taskID: "forge-try", + }, nil +} + +// RunTurn runs exactly one agent turn: the prompt plus the accumulated history, +// through the shared executor. It installs the egress-enforced client and the +// optional progress emitter on the context, appends the user + agent messages +// to history, and returns the agent's text reply. +func (s *LocalSession) RunTurn(ctx context.Context, prompt string, progress coreruntime.ProgressEmitter) (string, error) { + ctx = security.WithEgressClient(ctx, s.egressClient) + if progress != nil { + ctx = coreruntime.WithProgressEmitter(ctx, progress) + } + task := &a2a.Task{ID: s.taskID, History: s.history} + userMsg := &a2a.Message{Role: a2a.MessageRoleUser, Parts: []a2a.Part{a2a.NewTextPart(prompt)}} + + resp, err := s.executor.Execute(ctx, task, userMsg) + if err != nil { + return "", err + } + s.history = append(s.history, *userMsg) + if resp != nil { + s.history = append(s.history, *resp) + } + return messageText(resp), nil +} + +// AuditLogger exposes the session's audit logger so the visible-loop renderer +// (Phase 4) can attach itself as an additional sink. +func (s *LocalSession) AuditLogger() *coreruntime.AuditLogger { return s.audit } + +// Close stops the egress proxy and releases executor resources. +func (s *LocalSession) Close() error { + if s.proxyStop != nil { + s.proxyStop() + } + if s.executor != nil { + return s.executor.Close() + } + return nil +} + +// buildTryEgress mirrors Run()'s egress setup, trimmed to what the demo needs: +// the forge.yaml allowlist + skill-derived + LLM-provider domains, an +// in-process enforced client, and (outside a container, non-dev-open) a local +// proxy for subprocess skills. Both the enforcer and the proxy emit +// egress_allowed / egress_blocked audit events, exactly like the server path. +func (r *Runner) buildTryEgress(ctx context.Context, envVars map[string]string, audit *coreruntime.AuditLogger) (*http.Client, string, func()) { + noop := func() {} + + var domains []string + for _, d := range security.EffectiveEgressAllowlist(r.cfg.Config, nil) { + domains = append(domains, expandEgressDomains(d, envVars)...) + } + if r.derivedCLIConfig != nil { + for _, d := range r.derivedCLIConfig.EgressDomains { + domains = append(domains, expandEgressDomains(d, envVars)...) + } + } + domains = append(domains, security.LLMProviderDomains(r.cfg.Config)...) + domains = append(domains, security.LLMProviderEnvDomains(envVars)...) + + egressCfg, err := security.Resolve( + r.cfg.Config.Egress.Profile, + r.cfg.Config.Egress.Mode, + domains, + nil, + r.cfg.Config.Egress.Capabilities, + ) + if err != nil { + r.logger.Warn("egress resolve failed; using unenforced client", map[string]any{"error": err.Error()}) + return http.DefaultClient, "", noop + } + + allowPrivateIPs := false + if r.cfg.Config.Egress.AllowPrivateIPs != nil { + allowPrivateIPs = *r.cfg.Config.Egress.AllowPrivateIPs + } else if security.InContainer() { + allowPrivateIPs = true + } + + enforcer := security.NewEgressEnforcer(nil, egressCfg.Mode, egressCfg.AllDomains, allowPrivateIPs) + enforcer.OnAttempt = func(ctx context.Context, domain string, allowed bool) { + audit.EmitFromContext(ctx, coreruntime.AuditEvent{ + Event: egressEvent(allowed), + CorrelationID: coreruntime.CorrelationIDFromContext(ctx), + TaskID: coreruntime.TaskIDFromContext(ctx), + Fields: map[string]any{"domain": domain, "mode": string(egressCfg.Mode)}, + }) + } + egressClient := &http.Client{Transport: observability.WrapHTTPTransport(enforcer)} + + // Subprocess proxy for skill scripts (e.g. the weather skill's curl). + if security.InContainer() || egressCfg.Mode == security.ModeDevOpen { + return egressClient, "", noop + } + matcher := security.NewDomainMatcher(egressCfg.Mode, egressCfg.AllDomains) + proxy := security.NewEgressProxy(matcher, allowPrivateIPs) + proxy.OnAttempt = func(a security.EgressAttempt) { + audit.Emit(coreruntime.AuditEvent{ + Event: egressEvent(a.Allowed), + TaskID: a.TaskID, + CorrelationID: a.CorrelationID, + Fields: map[string]any{"domain": a.Domain, "mode": string(egressCfg.Mode), "source": "proxy"}, + }) + } + proxyURL, perr := proxy.Start(ctx) + if perr != nil { + r.logger.Warn("egress proxy failed to start; skills run unproxied", map[string]any{"error": perr.Error()}) + return egressClient, "", noop + } + return egressClient, proxyURL, func() { _ = proxy.Stop() } +} + +func egressEvent(allowed bool) string { + if allowed { + return coreruntime.AuditEgressAllowed + } + return coreruntime.AuditEgressBlocked +} + +// messageText concatenates the text parts of an a2a message. +func messageText(m *a2a.Message) string { + if m == nil { + return "" + } + var sb strings.Builder + for _, p := range m.Parts { + if p.Kind == a2a.PartKindText { + sb.WriteString(p.Text) + } + } + return sb.String() +} + +// osEnvMap snapshots the process environment as a map. +func osEnvMap() map[string]string { + env := os.Environ() + m := make(map[string]string, len(env)) + for _, kv := range env { + if i := strings.IndexByte(kv, '='); i > 0 { + m[kv[:i]] = kv[i+1:] + } + } + return m +} diff --git a/forge-cli/runtime/local_session_test.go b/forge-cli/runtime/local_session_test.go new file mode 100644 index 0000000..347416f --- /dev/null +++ b/forge-cli/runtime/local_session_test.go @@ -0,0 +1,59 @@ +package runtime + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/types" +) + +// TestLocalSession_RunTurn drives one real turn through the in-process executor +// against a mock OpenAI-compatible server — no real provider. It proves the +// assembly (client + tools + hooks + executor) runs and that history +// accumulates across the user and agent messages. +func TestLocalSession_RunTurn(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"chatcmpl-1","choices":[{"message":{"role":"assistant","content":"2 plus 2 is 4."},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)) + })) + defer srv.Close() + + t.Setenv("OPENAI_API_KEY", "test-key") + t.Setenv("OPENAI_BASE_URL", srv.URL) + + cfg := &types.ForgeConfig{ + AgentID: "quickstart", + Model: types.ModelRef{Provider: "openai", Name: "gpt-test"}, + Egress: types.EgressRef{Mode: "dev-open"}, // skip the subprocess proxy in tests + } + sess, err := NewLocalSession(context.Background(), LocalSessionOptions{ + Config: cfg, + WorkDir: t.TempDir(), + }) + if err != nil { + t.Fatalf("NewLocalSession: %v", err) + } + defer func() { _ = sess.Close() }() + + reply, err := sess.RunTurn(context.Background(), "what is 2+2?", nil) + if err != nil { + t.Fatalf("RunTurn: %v", err) + } + if !strings.Contains(reply, "4") { + t.Errorf("reply = %q, want it to contain 4", reply) + } + if len(sess.history) != 2 { + t.Fatalf("history len = %d, want 2 (user + agent)", len(sess.history)) + } + + // A second turn appends to the same history. + if _, err := sess.RunTurn(context.Background(), "and 3+3?", nil); err != nil { + t.Fatalf("second RunTurn: %v", err) + } + if len(sess.history) != 4 { + t.Errorf("history len after 2 turns = %d, want 4", len(sess.history)) + } +} From 93db524b0b26975822ec8ee9f0beb1a5a8be0747 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 20 Jul 2026 20:20:22 -0400 Subject: [PATCH 04/13] =?UTF-8?q?feat(cli):=20forge=20try=20=E2=80=94=20Ph?= =?UTF-8?q?ase=204=20visible-loop=20renderer=20(#350)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New forge-cli/internal/tryview.Renderer implements the audit Sink contract and maps the agent's own audit events to inline loop lines: tool calls (▸ tool name(args)), egress checks (▸ egress domain ✓/✗), guardrail blocks, and tool results (◂ preview), plus a dim compact 'audit {…}' summary after each reply. lipgloss orange accent, degrades to plain text with NO_COLOR / non-TTY. --quiet hides the loop; --audit echoes full NDJSON. LocalSession now builds its audit logger on a discard base (no stderr noise) and captures tool args/results (redacted) so the renderer can preview them; try.go attaches the renderer as an extra sink. Reads existing events only — no new audit constants, no forge-core TTY code. Verified: 12 renderer unit tests (event→line mapping, truncation, quiet, --audit echo, no-color degradation, summary reset) + an end-to-end LocalSession test rendering a real datetime_now tool call. Suites green; gofmt/lint clean. --- forge-cli/cmd/try.go | 28 ++- forge-cli/internal/tryview/renderer.go | 229 ++++++++++++++++++++ forge-cli/internal/tryview/renderer_test.go | 124 +++++++++++ forge-cli/runtime/local_session.go | 14 +- forge-cli/runtime/local_session_test.go | 52 +++++ 5 files changed, 438 insertions(+), 9 deletions(-) create mode 100644 forge-cli/internal/tryview/renderer.go create mode 100644 forge-cli/internal/tryview/renderer_test.go diff --git a/forge-cli/cmd/try.go b/forge-cli/cmd/try.go index 0f35297..062c344 100644 --- a/forge-cli/cmd/try.go +++ b/forge-cli/cmd/try.go @@ -14,6 +14,7 @@ import ( "time" "github.com/initializ/forge/forge-cli/config" + "github.com/initializ/forge/forge-cli/internal/tryview" "github.com/initializ/forge/forge-cli/runtime" "github.com/initializ/forge/forge-core/llm/oauth" "github.com/initializ/forge/forge-core/types" @@ -121,6 +122,12 @@ func runTry(cmd *cobra.Command, args []string) error { } defer func() { _ = sess.Close() }() + // Visible-loop renderer: inline tool/egress/guardrail lines from the + // agent's own audit stream. --quiet hides it; --audit shows full NDJSON. + color := term.IsTerminal(int(os.Stdout.Fd())) && os.Getenv("NO_COLOR") == "" + renderer := tryview.New(out, flags.quiet, flags.audit, color) + sess.AuditLogger().AddSink(renderer) + // Ctrl-C cancels the in-flight turn (executor cancels at the next // iteration / tool boundary) and ends the loop. ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) @@ -129,12 +136,12 @@ func runTry(cmd *cobra.Command, args []string) error { // --once: run a single turn (or, for an empty prompt, just load + exit). if flags.onceSet { if strings.TrimSpace(flags.once) != "" { - if err := tryOneTurn(ctx, sess, out, flags.once); err != nil { + if err := tryOneTurn(ctx, sess, renderer, out, flags.once); err != nil { return err } } } else { - if err := tryREPL(ctx, sess, cmd, flags); err != nil { + if err := tryREPL(ctx, sess, renderer, cmd, flags); err != nil { return err } } @@ -145,20 +152,24 @@ func runTry(cmd *cobra.Command, args []string) error { return nil } -// tryOneTurn runs a single non-interactive turn and prints the reply. -func tryOneTurn(ctx context.Context, sess *runtime.LocalSession, out io.Writer, prompt string) error { - reply, err := sess.RunTurn(ctx, prompt, nil) // Phase 4 wires the progress emitter +// tryOneTurn runs a single non-interactive turn and prints the reply, then the +// compact audit summary. +func tryOneTurn(ctx context.Context, sess *runtime.LocalSession, renderer *tryview.Renderer, out io.Writer, prompt string) error { + reply, err := sess.RunTurn(ctx, prompt, nil) if err != nil { return err } _, _ = fmt.Fprintf(out, "\nagent › %s\n", reply) + renderer.FlushSummary() return nil } // tryREPL reads one prompt per line and runs a turn each, keeping history in // the session. It exits on /exit, /quit, Ctrl-D (EOF), or a cancelled context. -// An optional positional prompt seeds the first turn. -func tryREPL(ctx context.Context, sess *runtime.LocalSession, cmd *cobra.Command, flags tryFlags) error { +// An optional positional prompt seeds the first turn. The renderer prints the +// inline loop (via the audit sink) during each turn; the compact summary +// prints after the reply. +func tryREPL(ctx context.Context, sess *runtime.LocalSession, renderer *tryview.Renderer, cmd *cobra.Command, flags tryFlags) error { out := cmd.OutOrStdout() scanner := bufio.NewScanner(cmd.InOrStdin()) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) @@ -178,7 +189,7 @@ func tryREPL(ctx context.Context, sess *runtime.LocalSession, cmd *cobra.Command if pending == "/exit" || pending == "/quit" { break } - reply, err := sess.RunTurn(ctx, pending, nil) // Phase 4 wires the progress emitter + reply, err := sess.RunTurn(ctx, pending, nil) pending = "" if err != nil { if ctx.Err() != nil { @@ -188,6 +199,7 @@ func tryREPL(ctx context.Context, sess *runtime.LocalSession, cmd *cobra.Command continue } _, _ = fmt.Fprintf(out, "\nagent › %s\n", reply) + renderer.FlushSummary() } return nil } diff --git a/forge-cli/internal/tryview/renderer.go b/forge-cli/internal/tryview/renderer.go new file mode 100644 index 0000000..896902d --- /dev/null +++ b/forge-cli/internal/tryview/renderer.go @@ -0,0 +1,229 @@ +// Package tryview renders the `forge try` agent's audit stream as inline, +// human-readable "loop" lines — tool calls, egress checks, guardrail blocks — +// so the first-run experience shows the agent working, not just its reply. +// +// The renderer implements the coreruntime audit Sink contract, so it attaches +// alongside (or instead of) the NDJSON sink. It reads existing audit events +// only; it never derives fields that aren't on the event. Styling degrades to +// plain text when color is unavailable. +package tryview + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Renderer maps audit events to inline loop lines. Zero value is not usable; +// construct with New. +type Renderer struct { + out io.Writer + style styles + quiet bool // hide every loop line and the summary + audit bool // echo full NDJSON instead of the mapped lines + turn turnSummary +} + +// New builds a Renderer writing to out. quiet hides the loop; audit echoes the +// full NDJSON stream; color enables the orange/grey styling (pass false for a +// non-TTY or NO_COLOR). +func New(out io.Writer, quiet, audit, color bool) *Renderer { + return &Renderer{out: out, style: newStyles(color), quiet: quiet, audit: audit} +} + +// turnSummary accumulates the salient facts of one turn for the compact audit +// line printed after the agent reply. +type turnSummary struct { + Tools []string `json:"tools,omitempty"` + Egress []string `json:"egress,omitempty"` // "domain:allow" / "domain:block" +} + +// auditLine is the subset of the audit event the renderer reads. +type auditLine struct { + Event string `json:"event"` + Fields map[string]any `json:"fields"` +} + +// Write implements the audit Sink interface. eventBytes is one NDJSON line +// (newline included). +func (r *Renderer) Write(_ context.Context, eventBytes []byte) error { + if r.audit { + _, _ = r.out.Write(eventBytes) + return nil + } + if r.quiet { + return nil + } + var e auditLine + if err := json.Unmarshal(eventBytes, &e); err != nil { + return nil // never fail the emit path over a render hiccup + } + if line := r.lineFor(&e); line != "" { + _, _ = fmt.Fprintln(r.out, line) + } + return nil +} + +// Close implements the audit Sink interface. +func (r *Renderer) Close(context.Context) error { return nil } + +// Name implements the audit Sink interface. +func (r *Renderer) Name() string { return "tryview" } + +// Stats implements the audit Sink interface. +func (r *Renderer) Stats() map[string]int64 { return nil } + +// FlushSummary prints the one-line compact audit summary for the turn (dim), +// then resets it. Call it after printing the agent reply. No-op under --quiet / +// --audit or when the turn touched no tools or egress. +func (r *Renderer) FlushSummary() { + defer func() { r.turn = turnSummary{} }() + if r.quiet || r.audit { + return + } + if len(r.turn.Tools) == 0 && len(r.turn.Egress) == 0 { + return + } + b, _ := json.Marshal(r.turn) + _, _ = fmt.Fprintln(r.out, " "+r.style.dim("audit "+string(b))) +} + +// lineFor maps one audit event to its rendered line, or "" to render nothing. +func (r *Renderer) lineFor(e *auditLine) string { + switch e.Event { + case "tool_exec": + return r.toolLine(e) + case "egress_allowed": + domain := fieldStr(e.Fields, "domain") + r.turn.Egress = append(r.turn.Egress, domain+":allow") + return r.prefix("egress") + " " + domain + " " + r.style.ok("✓ allowed") + case "egress_blocked": + domain := fieldStr(e.Fields, "domain") + r.turn.Egress = append(r.turn.Egress, domain+":block") + return r.prefix("egress") + " " + domain + " " + r.style.bad("✗ blocked") + case "guardrail_check": + if !guardBlocked(e.Fields) { + return "" + } + name := firstStr(e.Fields, "name", "guardrail", "rule") + return r.prefix("guard") + " " + name + " " + r.style.bad("✗ blocked") + } + return "" +} + +// toolLine renders a tool_exec start (the call) or end (the result / error). +func (r *Renderer) toolLine(e *auditLine) string { + tool := fieldStr(e.Fields, "tool") + switch fieldStr(e.Fields, "phase") { + case "start": + if tool != "" { + r.turn.Tools = append(r.turn.Tools, tool) + } + return r.prefix("tool") + " " + tool + "(" + argPreview(e.Fields["args"]) + ")" + case "end": + if msg := fieldStr(e.Fields, "error"); msg != "" { + return " " + r.style.label("◂") + " " + r.style.bad("error: "+truncate(oneLine(msg), 80)) + } + if res := fieldStr(e.Fields, "result"); res != "" { + return " " + r.style.label("◂") + " " + truncate(oneLine(res), 80) + } + } + return "" +} + +// prefix builds the styled " ▸