Merge pull request #171 from airtonix/expose-zot-agent-env
Expose zot execution to child processes
8 files · +186 -2
M README.md +19 -0
| @@ -814,6 +814,25 @@ Opening `/model` refreshes the router and lists every loaded model under provide | |||
| 814 | 814 | ||
| 815 | 815 | A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually. | |
| 816 | 816 | ||
| 817 | + | ## Child process environment | |
| 818 | + | ||
| 819 | + | Scripts and shell commands launched by zot inherit runtime metadata: | |
| 820 | + | ||
| 821 | + | | Variable | Meaning | | |
| 822 | + | | --- | --- | | |
| 823 | + | | `AI_AGENT` | Generic agent marker, set to `zot` | | |
| 824 | + | | `ZOT_AGENT` | Zot-specific process marker, set to `1` | | |
| 825 | + | | `ZOT_CHILD_SESSION` | Set to `1` for commands launched by a zot tool | | |
| 826 | + | | `ZOT_SESSION_ID` | Current session ID, when session persistence is enabled | | |
| 827 | + | | `ZOT_SESSION_FILE` | Absolute path to the current session file, when persisted | | |
| 828 | + | | `ZOT_PROVIDER` | Current model provider | | |
| 829 | + | | `ZOT_MODEL` | Current model ID | | |
| 830 | + | | `ZOT_REASONING_LEVEL` | Current reasoning level, when configured | | |
| 831 | + | ||
| 832 | + | These values contain no credentials. Use `ZOT_AGENT` or `AI_AGENT` to distinguish execution inside zot from a standalone invocation; use the session and model values for diagnostics or attribution. | |
| 833 | + | ||
| 834 | + | Interactive shell commands receive model metadata even with `--no-session`; only session ID and file are omitted. Newly launched commands reflect session switches, working-directory changes, and reasoning-level changes. Turning reasoning off removes `ZOT_REASONING_LEVEL`. Already-running subprocesses retain the environment they inherited at launch. | |
| 835 | + | ||
| 817 | 836 | ## Inline images | |
| 818 | 837 | ||
| 819 | 838 | When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: | |
| @@ -814,6 +814,25 @@ Opening `/model` refreshes the router and lists every loaded model under provide | |||||
| 814 | 814 | ||||
| 815 | A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually. | 815 | A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually. | ||
| 816 | 816 | ||||
| 817 | + | ## Child process environment | |||
| 818 | + | ||||
| 819 | + | Scripts and shell commands launched by zot inherit runtime metadata: | |||
| 820 | + | ||||
| 821 | + | | Variable | Meaning | | |||
| 822 | + | | --- | --- | | |||
| 823 | + | | `AI_AGENT` | Generic agent marker, set to `zot` | | |||
| 824 | + | | `ZOT_AGENT` | Zot-specific process marker, set to `1` | | |||
| 825 | + | | `ZOT_CHILD_SESSION` | Set to `1` for commands launched by a zot tool | | |||
| 826 | + | | `ZOT_SESSION_ID` | Current session ID, when session persistence is enabled | | |||
| 827 | + | | `ZOT_SESSION_FILE` | Absolute path to the current session file, when persisted | | |||
| 828 | + | | `ZOT_PROVIDER` | Current model provider | | |||
| 829 | + | | `ZOT_MODEL` | Current model ID | | |||
| 830 | + | | `ZOT_REASONING_LEVEL` | Current reasoning level, when configured | | |||
| 831 | + | ||||
| 832 | + | These values contain no credentials. Use `ZOT_AGENT` or `AI_AGENT` to distinguish execution inside zot from a standalone invocation; use the session and model values for diagnostics or attribution. | |||
| 833 | + | ||||
| 834 | + | Interactive shell commands receive model metadata even with `--no-session`; only session ID and file are omitted. Newly launched commands reflect session switches, working-directory changes, and reasoning-level changes. Turning reasoning off removes `ZOT_REASONING_LEVEL`. Already-running subprocesses retain the environment they inherited at launch. | |||
| 835 | + | ||||
| 817 | ## Inline images | 836 | ## Inline images | ||
| 818 | 837 | ||||
| 819 | When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: | 838 | When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: | ||
M packages/agent/args_test.go +15 -0
| @@ -98,6 +98,21 @@ func TestRunHelpHelperProcess(t *testing.T) { | |||
| 98 | 98 | os.Exit(0) | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | + | func TestRunSetsAgentEnvironment(t *testing.T) { | |
| 102 | + | t.Setenv("ZOT_AGENT", "") | |
| 103 | + | t.Setenv("AI_AGENT", "") | |
| 104 | + | isolateSessionEnvironment(t) | |
| 105 | + | if err := Run([]string{"--help"}, "test"); err != nil { | |
| 106 | + | t.Fatalf("Run returned %v", err) | |
| 107 | + | } | |
| 108 | + | if got := os.Getenv("ZOT_AGENT"); got != "1" { | |
| 109 | + | t.Fatalf("ZOT_AGENT = %q, want %q", got, "1") | |
| 110 | + | } | |
| 111 | + | if got := os.Getenv("AI_AGENT"); got != "zot" { | |
| 112 | + | t.Fatalf("AI_AGENT = %q, want %q", got, "zot") | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 101 | 116 | func TestHelpOutputStreams(t *testing.T) { | |
| 102 | 117 | run := func(args string) (stdout, stderr string, err error) { | |
| 103 | 118 | t.Helper() | |
| @@ -98,6 +98,21 @@ func TestRunHelpHelperProcess(t *testing.T) { | |||||
| 98 | os.Exit(0) | 98 | os.Exit(0) | ||
| 99 | } | 99 | } | ||
| 100 | 100 | ||||
| 101 | + | func TestRunSetsAgentEnvironment(t *testing.T) { | |||
| 102 | + | t.Setenv("ZOT_AGENT", "") | |||
| 103 | + | t.Setenv("AI_AGENT", "") | |||
| 104 | + | isolateSessionEnvironment(t) | |||
| 105 | + | if err := Run([]string{"--help"}, "test"); err != nil { | |||
| 106 | + | t.Fatalf("Run returned %v", err) | |||
| 107 | + | } | |||
| 108 | + | if got := os.Getenv("ZOT_AGENT"); got != "1" { | |||
| 109 | + | t.Fatalf("ZOT_AGENT = %q, want %q", got, "1") | |||
| 110 | + | } | |||
| 111 | + | if got := os.Getenv("AI_AGENT"); got != "zot" { | |||
| 112 | + | t.Fatalf("AI_AGENT = %q, want %q", got, "zot") | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 101 | func TestHelpOutputStreams(t *testing.T) { | 116 | func TestHelpOutputStreams(t *testing.T) { | ||
| 102 | run := func(args string) (stdout, stderr string, err error) { | 117 | run := func(args string) (stdout, stderr string, err error) { | ||
| 103 | t.Helper() | 118 | t.Helper() | ||
M packages/agent/cli.go +46 -1
| @@ -206,6 +206,16 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |||
| 206 | 206 | ||
| 207 | 207 | // Run is the top-level entrypoint for the zot binary. | |
| 208 | 208 | func Run(rawArgs []string, version string) error { | |
| 209 | + | // Mark the process and all child processes as running under zot. This | |
| 210 | + | // lets shell tools, extensions, and scripts distinguish zot execution | |
| 211 | + | // from an ordinary invocation. Clear per-session values because embedded | |
| 212 | + | // callers may invoke Run more than once in one process. | |
| 213 | + | _ = os.Setenv("AI_AGENT", "zot") | |
| 214 | + | _ = os.Setenv("ZOT_AGENT", "1") | |
| 215 | + | for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} { | |
| 216 | + | _ = os.Unsetenv(name) | |
| 217 | + | } | |
| 218 | + | ||
| 209 | 219 | // Apply network configuration before any subcommand can make an HTTP | |
| 210 | 220 | // request. Standard proxy environment variables retain precedence. | |
| 211 | 221 | applyConfiguredHTTPProxy() | |
| @@ -942,7 +952,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 942 | 952 | // (run on the TUI goroutine). Without this, a /sessions swap that | |
| 943 | 953 | // races with a finishing turn could double-write or lose messages. | |
| 944 | 954 | var persistMu sync.Mutex | |
| 945 | - | if !args.NoSess && ag != nil { | |
| 955 | + | if ag != nil { | |
| 946 | 956 | sess, _ = openOrCreateSession(args, r, ag, version) | |
| 947 | 957 | if ag != nil { | |
| 948 | 958 | sessBaselineMsgs = len(ag.Messages()) | |
| @@ -1060,6 +1070,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1060 | 1070 | sess = newSess | |
| 1061 | 1071 | currentAg.SetMessages(msgs) | |
| 1062 | 1072 | bindAgentSession(currentAg, sess) | |
| 1073 | + | setZotSessionEnvironment(r, sess) | |
| 1063 | 1074 | startExtensionSession(extMgr, currentAg, r.CWD, "session_switch") | |
| 1064 | 1075 | if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil { | |
| 1065 | 1076 | currentAg.SeedCost(cum) | |
| @@ -1198,6 +1209,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1198 | 1209 | bindAgentSession(newAg, newSess) | |
| 1199 | 1210 | } | |
| 1200 | 1211 | ||
| 1212 | + | r.Provider = newProvider | |
| 1213 | + | r.Model = newModel | |
| 1214 | + | r.Reasoning = newAg.Reasoning | |
| 1215 | + | setZotSessionEnvironment(r, sess) | |
| 1201 | 1216 | startExtensionSession(extMgr, newAg, absPath, "cwd_change") | |
| 1202 | 1217 | ||
| 1203 | 1218 | // Push the new state into the running Interactive. | |
| @@ -1457,6 +1472,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1457 | 1472 | }, | |
| 1458 | 1473 | NoYolo: args.NoYolo, | |
| 1459 | 1474 | ConfirmGate: confirmGate, | |
| 1475 | + | OnReasoningChanged: func(level string) { | |
| 1476 | + | r.Reasoning = level | |
| 1477 | + | setZotSessionEnvironment(r, sess) | |
| 1478 | + | }, | |
| 1460 | 1479 | PersistModel: func(providerName, model string) { | |
| 1461 | 1480 | // Update config.json so next launch uses the same pick. | |
| 1462 | 1481 | cfg, _ := LoadConfig() | |
| @@ -1467,6 +1486,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1467 | 1486 | if sess != nil { | |
| 1468 | 1487 | _ = sess.UpdateModel(providerName, model) | |
| 1469 | 1488 | } | |
| 1489 | + | r.Provider = providerName | |
| 1490 | + | r.Model = model | |
| 1491 | + | setZotSessionEnvironment(r, sess) | |
| 1470 | 1492 | }, | |
| 1471 | 1493 | }) | |
| 1472 | 1494 | ||
| @@ -1543,6 +1565,7 @@ func agentSessionsRoot(root string, args Args) string { | |||
| 1543 | 1565 | // exists, its id is bound onto ag so providers can sticky-route. | |
| 1544 | 1566 | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | |
| 1545 | 1567 | if args.NoSess { | |
| 1568 | + | setZotSessionEnvironment(r, nil) | |
| 1546 | 1569 | return nil, nil | |
| 1547 | 1570 | } | |
| 1548 | 1571 | // Sweep meta-only files left over from older zot versions (and from | |
| @@ -1600,9 +1623,31 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||
| 1600 | 1623 | } | |
| 1601 | 1624 | } | |
| 1602 | 1625 | bindAgentSession(ag, s) | |
| 1626 | + | setZotSessionEnvironment(r, s) | |
| 1603 | 1627 | return s, nil | |
| 1604 | 1628 | } | |
| 1605 | 1629 | ||
| 1630 | + | // setZotSessionEnvironment publishes the non-secret runtime metadata that | |
| 1631 | + | // zot's child processes can use for attribution and diagnostics. | |
| 1632 | + | func setZotSessionEnvironment(r Resolved, sess *core.Session) { | |
| 1633 | + | values := map[string]string{ | |
| 1634 | + | "ZOT_PROVIDER": r.Provider, | |
| 1635 | + | "ZOT_MODEL": r.Model, | |
| 1636 | + | "ZOT_REASONING_LEVEL": r.Reasoning, | |
| 1637 | + | } | |
| 1638 | + | if sess != nil { | |
| 1639 | + | values["ZOT_SESSION_ID"] = sess.ID | |
| 1640 | + | values["ZOT_SESSION_FILE"] = sess.Path | |
| 1641 | + | } | |
| 1642 | + | for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} { | |
| 1643 | + | if value := values[name]; value != "" { | |
| 1644 | + | _ = os.Setenv(name, value) | |
| 1645 | + | } else { | |
| 1646 | + | _ = os.Unsetenv(name) | |
| 1647 | + | } | |
| 1648 | + | } | |
| 1649 | + | } | |
| 1650 | + | ||
| 1606 | 1651 | func pickSession(root, cwd string) (string, error) { | |
| 1607 | 1652 | files := core.ListSessions(root, cwd) | |
| 1608 | 1653 | if len(files) == 0 { | |
| @@ -206,6 +206,16 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |||||
| 206 | 206 | ||||
| 207 | // Run is the top-level entrypoint for the zot binary. | 207 | // Run is the top-level entrypoint for the zot binary. | ||
| 208 | func Run(rawArgs []string, version string) error { | 208 | func Run(rawArgs []string, version string) error { | ||
| 209 | + | // Mark the process and all child processes as running under zot. This | |||
| 210 | + | // lets shell tools, extensions, and scripts distinguish zot execution | |||
| 211 | + | // from an ordinary invocation. Clear per-session values because embedded | |||
| 212 | + | // callers may invoke Run more than once in one process. | |||
| 213 | + | _ = os.Setenv("AI_AGENT", "zot") | |||
| 214 | + | _ = os.Setenv("ZOT_AGENT", "1") | |||
| 215 | + | for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} { | |||
| 216 | + | _ = os.Unsetenv(name) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 209 | // Apply network configuration before any subcommand can make an HTTP | 219 | // Apply network configuration before any subcommand can make an HTTP | ||
| 210 | // request. Standard proxy environment variables retain precedence. | 220 | // request. Standard proxy environment variables retain precedence. | ||
| 211 | applyConfiguredHTTPProxy() | 221 | applyConfiguredHTTPProxy() | ||
| @@ -942,7 +952,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 942 | // (run on the TUI goroutine). Without this, a /sessions swap that | 952 | // (run on the TUI goroutine). Without this, a /sessions swap that | ||
| 943 | // races with a finishing turn could double-write or lose messages. | 953 | // races with a finishing turn could double-write or lose messages. | ||
| 944 | var persistMu sync.Mutex | 954 | var persistMu sync.Mutex | ||
| 945 | - | if !args.NoSess && ag != nil { | 955 | + | if ag != nil { |
| 946 | sess, _ = openOrCreateSession(args, r, ag, version) | 956 | sess, _ = openOrCreateSession(args, r, ag, version) | ||
| 947 | if ag != nil { | 957 | if ag != nil { | ||
| 948 | sessBaselineMsgs = len(ag.Messages()) | 958 | sessBaselineMsgs = len(ag.Messages()) | ||
| @@ -1060,6 +1070,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1060 | sess = newSess | 1070 | sess = newSess | ||
| 1061 | currentAg.SetMessages(msgs) | 1071 | currentAg.SetMessages(msgs) | ||
| 1062 | bindAgentSession(currentAg, sess) | 1072 | bindAgentSession(currentAg, sess) | ||
| 1073 | + | setZotSessionEnvironment(r, sess) | |||
| 1063 | startExtensionSession(extMgr, currentAg, r.CWD, "session_switch") | 1074 | startExtensionSession(extMgr, currentAg, r.CWD, "session_switch") | ||
| 1064 | if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil { | 1075 | if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil { | ||
| 1065 | currentAg.SeedCost(cum) | 1076 | currentAg.SeedCost(cum) | ||
| @@ -1198,6 +1209,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1198 | bindAgentSession(newAg, newSess) | 1209 | bindAgentSession(newAg, newSess) | ||
| 1199 | } | 1210 | } | ||
| 1200 | 1211 | ||||
| 1212 | + | r.Provider = newProvider | |||
| 1213 | + | r.Model = newModel | |||
| 1214 | + | r.Reasoning = newAg.Reasoning | |||
| 1215 | + | setZotSessionEnvironment(r, sess) | |||
| 1201 | startExtensionSession(extMgr, newAg, absPath, "cwd_change") | 1216 | startExtensionSession(extMgr, newAg, absPath, "cwd_change") | ||
| 1202 | 1217 | ||||
| 1203 | // Push the new state into the running Interactive. | 1218 | // Push the new state into the running Interactive. | ||
| @@ -1457,6 +1472,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1457 | }, | 1472 | }, | ||
| 1458 | NoYolo: args.NoYolo, | 1473 | NoYolo: args.NoYolo, | ||
| 1459 | ConfirmGate: confirmGate, | 1474 | ConfirmGate: confirmGate, | ||
| 1475 | + | OnReasoningChanged: func(level string) { | |||
| 1476 | + | r.Reasoning = level | |||
| 1477 | + | setZotSessionEnvironment(r, sess) | |||
| 1478 | + | }, | |||
| 1460 | PersistModel: func(providerName, model string) { | 1479 | PersistModel: func(providerName, model string) { | ||
| 1461 | // Update config.json so next launch uses the same pick. | 1480 | // Update config.json so next launch uses the same pick. | ||
| 1462 | cfg, _ := LoadConfig() | 1481 | cfg, _ := LoadConfig() | ||
| @@ -1467,6 +1486,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1467 | if sess != nil { | 1486 | if sess != nil { | ||
| 1468 | _ = sess.UpdateModel(providerName, model) | 1487 | _ = sess.UpdateModel(providerName, model) | ||
| 1469 | } | 1488 | } | ||
| 1489 | + | r.Provider = providerName | |||
| 1490 | + | r.Model = model | |||
| 1491 | + | setZotSessionEnvironment(r, sess) | |||
| 1470 | }, | 1492 | }, | ||
| 1471 | }) | 1493 | }) | ||
| 1472 | 1494 | ||||
| @@ -1543,6 +1565,7 @@ func agentSessionsRoot(root string, args Args) string { | |||||
| 1543 | // exists, its id is bound onto ag so providers can sticky-route. | 1565 | // exists, its id is bound onto ag so providers can sticky-route. | ||
| 1544 | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | 1566 | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | ||
| 1545 | if args.NoSess { | 1567 | if args.NoSess { | ||
| 1568 | + | setZotSessionEnvironment(r, nil) | |||
| 1546 | return nil, nil | 1569 | return nil, nil | ||
| 1547 | } | 1570 | } | ||
| 1548 | // Sweep meta-only files left over from older zot versions (and from | 1571 | // Sweep meta-only files left over from older zot versions (and from | ||
| @@ -1600,9 +1623,31 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||||
| 1600 | } | 1623 | } | ||
| 1601 | } | 1624 | } | ||
| 1602 | bindAgentSession(ag, s) | 1625 | bindAgentSession(ag, s) | ||
| 1626 | + | setZotSessionEnvironment(r, s) | |||
| 1603 | return s, nil | 1627 | return s, nil | ||
| 1604 | } | 1628 | } | ||
| 1605 | 1629 | ||||
| 1630 | + | // setZotSessionEnvironment publishes the non-secret runtime metadata that | |||
| 1631 | + | // zot's child processes can use for attribution and diagnostics. | |||
| 1632 | + | func setZotSessionEnvironment(r Resolved, sess *core.Session) { | |||
| 1633 | + | values := map[string]string{ | |||
| 1634 | + | "ZOT_PROVIDER": r.Provider, | |||
| 1635 | + | "ZOT_MODEL": r.Model, | |||
| 1636 | + | "ZOT_REASONING_LEVEL": r.Reasoning, | |||
| 1637 | + | } | |||
| 1638 | + | if sess != nil { | |||
| 1639 | + | values["ZOT_SESSION_ID"] = sess.ID | |||
| 1640 | + | values["ZOT_SESSION_FILE"] = sess.Path | |||
| 1641 | + | } | |||
| 1642 | + | for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} { | |||
| 1643 | + | if value := values[name]; value != "" { | |||
| 1644 | + | _ = os.Setenv(name, value) | |||
| 1645 | + | } else { | |||
| 1646 | + | _ = os.Unsetenv(name) | |||
| 1647 | + | } | |||
| 1648 | + | } | |||
| 1649 | + | } | |||
| 1650 | + | ||||
| 1606 | func pickSession(root, cwd string) (string, error) { | 1651 | func pickSession(root, cwd string) (string, error) { | ||
| 1607 | files := core.ListSessions(root, cwd) | 1652 | files := core.ListSessions(root, cwd) | ||
| 1608 | if len(files) == 0 { | 1653 | if len(files) == 0 { | ||
M packages/agent/modes/interactive.go +6 -0
| @@ -269,6 +269,9 @@ type InteractiveConfig struct { | |||
| 269 | 269 | // write a new meta row so resume picks up the same model. | |
| 270 | 270 | PersistModel func(providerName, model string) | |
| 271 | 271 | ||
| 272 | + | // OnReasoningChanged reports the normalized level after updating the live agent. | |
| 273 | + | OnReasoningChanged func(level string) | |
| 274 | + | ||
| 272 | 275 | OnAssistant func(m provider.Message) | |
| 273 | 276 | OnToolResult func(id string, r core.ToolResult) | |
| 274 | 277 | ||
| @@ -4196,6 +4199,9 @@ func (i *Interactive) applyReasoningSetting(level string) { | |||
| 4196 | 4199 | i.statusOK = "reasoning level " + label | |
| 4197 | 4200 | i.statusErr = "" | |
| 4198 | 4201 | i.mu.Unlock() | |
| 4202 | + | if i.cfg.OnReasoningChanged != nil { | |
| 4203 | + | i.cfg.OnReasoningChanged(level) | |
| 4204 | + | } | |
| 4199 | 4205 | } | |
| 4200 | 4206 | ||
| 4201 | 4207 | // buildStudyPrompt returns the canned prompt the /study command | |
| @@ -269,6 +269,9 @@ type InteractiveConfig struct { | |||||
| 269 | // write a new meta row so resume picks up the same model. | 269 | // write a new meta row so resume picks up the same model. | ||
| 270 | PersistModel func(providerName, model string) | 270 | PersistModel func(providerName, model string) | ||
| 271 | 271 | ||||
| 272 | + | // OnReasoningChanged reports the normalized level after updating the live agent. | |||
| 273 | + | OnReasoningChanged func(level string) | |||
| 274 | + | ||||
| 272 | OnAssistant func(m provider.Message) | 275 | OnAssistant func(m provider.Message) | ||
| 273 | OnToolResult func(id string, r core.ToolResult) | 276 | OnToolResult func(id string, r core.ToolResult) | ||
| 274 | 277 | ||||
| @@ -4196,6 +4199,9 @@ func (i *Interactive) applyReasoningSetting(level string) { | |||||
| 4196 | i.statusOK = "reasoning level " + label | 4199 | i.statusOK = "reasoning level " + label | ||
| 4197 | i.statusErr = "" | 4200 | i.statusErr = "" | ||
| 4198 | i.mu.Unlock() | 4201 | i.mu.Unlock() | ||
| 4202 | + | if i.cfg.OnReasoningChanged != nil { | |||
| 4203 | + | i.cfg.OnReasoningChanged(level) | |||
| 4204 | + | } | |||
| 4199 | } | 4205 | } | ||
| 4200 | 4206 | ||||
| 4201 | // buildStudyPrompt returns the canned prompt the /study command | 4207 | // buildStudyPrompt returns the canned prompt the /study command | ||
M packages/agent/modes/interactive_model_test.go +17 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |
| 9 | 10 | "github.com/patriceckhart/zot/packages/tui" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -81,6 +82,22 @@ func TestReasoningCommandOpensDirectSelector(t *testing.T) { | |||
| 81 | 82 | } | |
| 82 | 83 | } | |
| 83 | 84 | ||
| 85 | + | func TestReasoningSettingNotifiesRuntime(t *testing.T) { | |
| 86 | + | i := &Interactive{agent: &core.Agent{Reasoning: "high"}} | |
| 87 | + | var levels []string | |
| 88 | + | i.cfg.OnReasoningChanged = func(level string) { | |
| 89 | + | if i.agent.Reasoning != level { | |
| 90 | + | t.Fatalf("callback ran before agent update: got %q, want %q", i.agent.Reasoning, level) | |
| 91 | + | } | |
| 92 | + | levels = append(levels, level) | |
| 93 | + | } | |
| 94 | + | i.applyReasoningSetting("medium") | |
| 95 | + | i.applyReasoningSetting("off") | |
| 96 | + | if !slices.Equal(levels, []string{"medium", ""}) { | |
| 97 | + | t.Fatalf("reasoning notifications = %q", levels) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 84 | 101 | func TestNormalizeModelQueryKeepsPresetAtSign(t *testing.T) { | |
| 85 | 102 | if got := normalizeModelQuery("@preset/flash"); got != "@presetflash" { | |
| 86 | 103 | t.Fatalf("id = %q; want @presetflash", got) | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 9 | "github.com/patriceckhart/zot/packages/tui" | 10 | "github.com/patriceckhart/zot/packages/tui" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| @@ -81,6 +82,22 @@ func TestReasoningCommandOpensDirectSelector(t *testing.T) { | |||||
| 81 | } | 82 | } | ||
| 82 | } | 83 | } | ||
| 83 | 84 | ||||
| 85 | + | func TestReasoningSettingNotifiesRuntime(t *testing.T) { | |||
| 86 | + | i := &Interactive{agent: &core.Agent{Reasoning: "high"}} | |||
| 87 | + | var levels []string | |||
| 88 | + | i.cfg.OnReasoningChanged = func(level string) { | |||
| 89 | + | if i.agent.Reasoning != level { | |||
| 90 | + | t.Fatalf("callback ran before agent update: got %q, want %q", i.agent.Reasoning, level) | |||
| 91 | + | } | |||
| 92 | + | levels = append(levels, level) | |||
| 93 | + | } | |||
| 94 | + | i.applyReasoningSetting("medium") | |||
| 95 | + | i.applyReasoningSetting("off") | |||
| 96 | + | if !slices.Equal(levels, []string{"medium", ""}) { | |||
| 97 | + | t.Fatalf("reasoning notifications = %q", levels) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 84 | func TestNormalizeModelQueryKeepsPresetAtSign(t *testing.T) { | 101 | func TestNormalizeModelQueryKeepsPresetAtSign(t *testing.T) { | ||
| 85 | if got := normalizeModelQuery("@preset/flash"); got != "@presetflash" { | 102 | if got := normalizeModelQuery("@preset/flash"); got != "@presetflash" { | ||
| 86 | t.Fatalf("id = %q; want @presetflash", got) | 103 | t.Fatalf("id = %q; want @presetflash", got) | ||
A packages/agent/session_environment_test.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func isolateSessionEnvironment(t *testing.T) { | |
| 12 | + | t.Helper() | |
| 13 | + | for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} { | |
| 14 | + | t.Setenv(name, "inherited") | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func assertSessionEnvironment(t *testing.T, r Resolved, sess *core.Session) { | |
| 19 | + | t.Helper() | |
| 20 | + | want := map[string]string{ | |
| 21 | + | "ZOT_SESSION_ID": "", "ZOT_SESSION_FILE": "", | |
| 22 | + | "ZOT_PROVIDER": r.Provider, "ZOT_MODEL": r.Model, "ZOT_REASONING_LEVEL": r.Reasoning, | |
| 23 | + | } | |
| 24 | + | if sess != nil { | |
| 25 | + | want["ZOT_SESSION_ID"] = sess.ID | |
| 26 | + | want["ZOT_SESSION_FILE"] = sess.Path | |
| 27 | + | } | |
| 28 | + | for name, value := range want { | |
| 29 | + | got, present := os.LookupEnv(name) | |
| 30 | + | if got != value || present != (value != "") { | |
| 31 | + | t.Errorf("%s = %q (present=%v), want %q (present=%v)", name, got, present, value, value != "") | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestOpenSessionWithoutPersistencePublishesModelEnvironment(t *testing.T) { | |
| 37 | + | isolateSessionEnvironment(t) | |
| 38 | + | r := Resolved{Provider: "test-provider", Model: "test-model", Reasoning: "high"} | |
| 39 | + | sess, err := openOrCreateSession(Args{NoSess: true}, r, nil, "test") | |
| 40 | + | if err != nil || sess != nil { | |
| 41 | + | t.Fatalf("openOrCreateSession = %v, %v; want nil, nil", sess, err) | |
| 42 | + | } | |
| 43 | + | assertSessionEnvironment(t, r, nil) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestSessionEnvironmentReplacesSessionAndClearsReasoning(t *testing.T) { | |
| 47 | + | isolateSessionEnvironment(t) | |
| 48 | + | r := Resolved{Provider: "old-provider", Model: "old-model", Reasoning: "high"} | |
| 49 | + | oldSession := &core.Session{ID: "old", Path: filepath.Join(t.TempDir(), "old.jsonl")} | |
| 50 | + | setZotSessionEnvironment(r, oldSession) | |
| 51 | + | assertSessionEnvironment(t, r, oldSession) | |
| 52 | + | ||
| 53 | + | // A directory change replaces the session and may rebuild a different model. | |
| 54 | + | r.Provider, r.Model, r.Reasoning = "new-provider", "new-model", "medium" | |
| 55 | + | newSession := &core.Session{ID: "new", Path: filepath.Join(t.TempDir(), "new.jsonl")} | |
| 56 | + | setZotSessionEnvironment(r, newSession) | |
| 57 | + | assertSessionEnvironment(t, r, newSession) | |
| 58 | + | ||
| 59 | + | r.Reasoning = "" | |
| 60 | + | setZotSessionEnvironment(r, newSession) | |
| 61 | + | assertSessionEnvironment(t, r, newSession) | |
| 62 | + | ||
| 63 | + | setZotSessionEnvironment(r, nil) | |
| 64 | + | assertSessionEnvironment(t, r, nil) | |
| 65 | + | } | |
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func isolateSessionEnvironment(t *testing.T) { | |||
| 12 | + | t.Helper() | |||
| 13 | + | for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} { | |||
| 14 | + | t.Setenv(name, "inherited") | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func assertSessionEnvironment(t *testing.T, r Resolved, sess *core.Session) { | |||
| 19 | + | t.Helper() | |||
| 20 | + | want := map[string]string{ | |||
| 21 | + | "ZOT_SESSION_ID": "", "ZOT_SESSION_FILE": "", | |||
| 22 | + | "ZOT_PROVIDER": r.Provider, "ZOT_MODEL": r.Model, "ZOT_REASONING_LEVEL": r.Reasoning, | |||
| 23 | + | } | |||
| 24 | + | if sess != nil { | |||
| 25 | + | want["ZOT_SESSION_ID"] = sess.ID | |||
| 26 | + | want["ZOT_SESSION_FILE"] = sess.Path | |||
| 27 | + | } | |||
| 28 | + | for name, value := range want { | |||
| 29 | + | got, present := os.LookupEnv(name) | |||
| 30 | + | if got != value || present != (value != "") { | |||
| 31 | + | t.Errorf("%s = %q (present=%v), want %q (present=%v)", name, got, present, value, value != "") | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestOpenSessionWithoutPersistencePublishesModelEnvironment(t *testing.T) { | |||
| 37 | + | isolateSessionEnvironment(t) | |||
| 38 | + | r := Resolved{Provider: "test-provider", Model: "test-model", Reasoning: "high"} | |||
| 39 | + | sess, err := openOrCreateSession(Args{NoSess: true}, r, nil, "test") | |||
| 40 | + | if err != nil || sess != nil { | |||
| 41 | + | t.Fatalf("openOrCreateSession = %v, %v; want nil, nil", sess, err) | |||
| 42 | + | } | |||
| 43 | + | assertSessionEnvironment(t, r, nil) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestSessionEnvironmentReplacesSessionAndClearsReasoning(t *testing.T) { | |||
| 47 | + | isolateSessionEnvironment(t) | |||
| 48 | + | r := Resolved{Provider: "old-provider", Model: "old-model", Reasoning: "high"} | |||
| 49 | + | oldSession := &core.Session{ID: "old", Path: filepath.Join(t.TempDir(), "old.jsonl")} | |||
| 50 | + | setZotSessionEnvironment(r, oldSession) | |||
| 51 | + | assertSessionEnvironment(t, r, oldSession) | |||
| 52 | + | ||||
| 53 | + | // A directory change replaces the session and may rebuild a different model. | |||
| 54 | + | r.Provider, r.Model, r.Reasoning = "new-provider", "new-model", "medium" | |||
| 55 | + | newSession := &core.Session{ID: "new", Path: filepath.Join(t.TempDir(), "new.jsonl")} | |||
| 56 | + | setZotSessionEnvironment(r, newSession) | |||
| 57 | + | assertSessionEnvironment(t, r, newSession) | |||
| 58 | + | ||||
| 59 | + | r.Reasoning = "" | |||
| 60 | + | setZotSessionEnvironment(r, newSession) | |||
| 61 | + | assertSessionEnvironment(t, r, newSession) | |||
| 62 | + | ||||
| 63 | + | setZotSessionEnvironment(r, nil) | |||
| 64 | + | assertSessionEnvironment(t, r, nil) | |||
| 65 | + | } | |||
M packages/agent/tools/bash.go +3 -1
| @@ -81,7 +81,9 @@ func executeShell(ctx context.Context, raw json.RawMessage, progress func(string | |||
| 81 | 81 | return core.ToolResult{}, err | |
| 82 | 82 | } | |
| 83 | 83 | cmd.Dir = cwd | |
| 84 | - | cmd.Env = os.Environ() | |
| 84 | + | // Mark direct tool subprocesses separately from the zot process itself. | |
| 85 | + | // Descendants inherit this marker as usual. | |
| 86 | + | cmd.Env = append(os.Environ(), "ZOT_CHILD_SESSION=1") | |
| 85 | 87 | // Bound output draining if descendants retain handles after cancellation. | |
| 86 | 88 | cmd.WaitDelay = 2 * time.Second | |
| 87 | 89 | setProcessGroup(cmd) | |
| @@ -81,7 +81,9 @@ func executeShell(ctx context.Context, raw json.RawMessage, progress func(string | |||||
| 81 | return core.ToolResult{}, err | 81 | return core.ToolResult{}, err | ||
| 82 | } | 82 | } | ||
| 83 | cmd.Dir = cwd | 83 | cmd.Dir = cwd | ||
| 84 | - | cmd.Env = os.Environ() | 84 | + | // Mark direct tool subprocesses separately from the zot process itself. |
| 85 | + | // Descendants inherit this marker as usual. | |||
| 86 | + | cmd.Env = append(os.Environ(), "ZOT_CHILD_SESSION=1") | |||
| 85 | // Bound output draining if descendants retain handles after cancellation. | 87 | // Bound output draining if descendants retain handles after cancellation. | ||
| 86 | cmd.WaitDelay = 2 * time.Second | 88 | cmd.WaitDelay = 2 * time.Second | ||
| 87 | setProcessGroup(cmd) | 89 | setProcessGroup(cmd) | ||
M packages/agent/tools/tools_test.go +15 -0
| @@ -355,6 +355,21 @@ func TestBashSuccess(t *testing.T) { | |||
| 355 | 355 | } | |
| 356 | 356 | } | |
| 357 | 357 | ||
| 358 | + | func TestBashExposesChildSessionMarker(t *testing.T) { | |
| 359 | + | if runtime.GOOS == "windows" { | |
| 360 | + | t.Skip("posix shell only") | |
| 361 | + | } | |
| 362 | + | tool := &BashTool{CWD: t.TempDir()} | |
| 363 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "printf '%s' \"$ZOT_CHILD_SESSION\""}), nil) | |
| 364 | + | if err != nil { | |
| 365 | + | t.Fatal(err) | |
| 366 | + | } | |
| 367 | + | got := res.Content[0].(provider.TextBlock).Text | |
| 368 | + | if !strings.Contains(got, "1") { | |
| 369 | + | t.Fatalf("ZOT_CHILD_SESSION = %q, want 1", got) | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 358 | 373 | func TestBashSyntax(t *testing.T) { | |
| 359 | 374 | if runtime.GOOS == "windows" { | |
| 360 | 375 | t.Skip("bash syntax is not used on Windows") | |
| @@ -355,6 +355,21 @@ func TestBashSuccess(t *testing.T) { | |||||
| 355 | } | 355 | } | ||
| 356 | } | 356 | } | ||
| 357 | 357 | ||||
| 358 | + | func TestBashExposesChildSessionMarker(t *testing.T) { | |||
| 359 | + | if runtime.GOOS == "windows" { | |||
| 360 | + | t.Skip("posix shell only") | |||
| 361 | + | } | |||
| 362 | + | tool := &BashTool{CWD: t.TempDir()} | |||
| 363 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "printf '%s' \"$ZOT_CHILD_SESSION\""}), nil) | |||
| 364 | + | if err != nil { | |||
| 365 | + | t.Fatal(err) | |||
| 366 | + | } | |||
| 367 | + | got := res.Content[0].(provider.TextBlock).Text | |||
| 368 | + | if !strings.Contains(got, "1") { | |||
| 369 | + | t.Fatalf("ZOT_CHILD_SESSION = %q, want 1", got) | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | ||||
| 358 | func TestBashSyntax(t *testing.T) { | 373 | func TestBashSyntax(t *testing.T) { | ||
| 359 | if runtime.GOOS == "windows" { | 374 | if runtime.GOOS == "windows" { | ||
| 360 | t.Skip("bash syntax is not used on Windows") | 375 | t.Skip("bash syntax is not used on Windows") | ||