Expose agent session metadata to child processes
- commit
- 6506e47cb280ca63b5a1f8470cca35628683797f
- author
- Zeno Jiricek · · 2026-09-08 23:26 UTC
- committer
- Zeno Jiricek · · 2026-09-08 23:30 UTC
- parent
- 1b0d383
- signature
- unsigned
5 files · +68 -3
M README.md +14 -1
| @@ -816,7 +816,20 @@ A model installed through Ollama is kept in Ollama's internal storage and is not | |||
| 816 | 816 | ||
| 817 | 817 | ## Inline images | |
| 818 | 818 | ||
| 819 | - | Scripts and shell commands launched by zot inherit `ZOT_AGENT=1`. Use this signal when a script needs to distinguish execution inside zot from a standalone invocation. | |
| 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. | |
| 820 | 833 | ||
| 821 | 834 | 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: | |
| 822 | 835 | ||
| @@ -816,7 +816,20 @@ A model installed through Ollama is kept in Ollama's internal storage and is not | |||||
| 816 | 816 | ||||
| 817 | ## Inline images | 817 | ## Inline images | ||
| 818 | 818 | ||||
| 819 | - | Scripts and shell commands launched by zot inherit `ZOT_AGENT=1`. Use this signal when a script needs to distinguish execution inside zot from a standalone invocation. | 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. | |||
| 820 | 833 | ||||
| 821 | 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: | 834 | 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: | ||
| 822 | 835 | ||||
M packages/agent/args_test.go +3 -0
| @@ -106,6 +106,9 @@ func TestRunSetsAgentEnvironment(t *testing.T) { | |||
| 106 | 106 | if got := os.Getenv("ZOT_AGENT"); got != "1" { | |
| 107 | 107 | t.Fatalf("ZOT_AGENT = %q, want %q", got, "1") | |
| 108 | 108 | } | |
| 109 | + | if got := os.Getenv("AI_AGENT"); got != "zot" { | |
| 110 | + | t.Fatalf("AI_AGENT = %q, want %q", got, "zot") | |
| 111 | + | } | |
| 109 | 112 | } | |
| 110 | 113 | ||
| 111 | 114 | func TestHelpOutputStreams(t *testing.T) { | |
| @@ -106,6 +106,9 @@ func TestRunSetsAgentEnvironment(t *testing.T) { | |||||
| 106 | if got := os.Getenv("ZOT_AGENT"); got != "1" { | 106 | if got := os.Getenv("ZOT_AGENT"); got != "1" { | ||
| 107 | t.Fatalf("ZOT_AGENT = %q, want %q", got, "1") | 107 | t.Fatalf("ZOT_AGENT = %q, want %q", got, "1") | ||
| 108 | } | 108 | } | ||
| 109 | + | if got := os.Getenv("AI_AGENT"); got != "zot" { | |||
| 110 | + | t.Fatalf("AI_AGENT = %q, want %q", got, "zot") | |||
| 111 | + | } | |||
| 109 | } | 112 | } | ||
| 110 | 113 | ||||
| 111 | func TestHelpOutputStreams(t *testing.T) { | 114 | func TestHelpOutputStreams(t *testing.T) { | ||
M packages/agent/cli.go +33 -1
| @@ -208,8 +208,13 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |||
| 208 | 208 | func Run(rawArgs []string, version string) error { | |
| 209 | 209 | // Mark the process and all child processes as running under zot. This | |
| 210 | 210 | // lets shell tools, extensions, and scripts distinguish zot execution | |
| 211 | - | // from an ordinary invocation. | |
| 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") | |
| 212 | 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 | + | } | |
| 213 | 218 | ||
| 214 | 219 | // Apply network configuration before any subcommand can make an HTTP | |
| 215 | 220 | // request. Standard proxy environment variables retain precedence. | |
| @@ -1065,6 +1070,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1065 | 1070 | sess = newSess | |
| 1066 | 1071 | currentAg.SetMessages(msgs) | |
| 1067 | 1072 | bindAgentSession(currentAg, sess) | |
| 1073 | + | setZotSessionEnvironment(r, sess) | |
| 1068 | 1074 | startExtensionSession(extMgr, currentAg, r.CWD, "session_switch") | |
| 1069 | 1075 | if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil { | |
| 1070 | 1076 | currentAg.SeedCost(cum) | |
| @@ -1472,6 +1478,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1472 | 1478 | if sess != nil { | |
| 1473 | 1479 | _ = sess.UpdateModel(providerName, model) | |
| 1474 | 1480 | } | |
| 1481 | + | r.Provider = providerName | |
| 1482 | + | r.Model = model | |
| 1483 | + | setZotSessionEnvironment(r, sess) | |
| 1475 | 1484 | }, | |
| 1476 | 1485 | }) | |
| 1477 | 1486 | ||
| @@ -1548,6 +1557,7 @@ func agentSessionsRoot(root string, args Args) string { | |||
| 1548 | 1557 | // exists, its id is bound onto ag so providers can sticky-route. | |
| 1549 | 1558 | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | |
| 1550 | 1559 | if args.NoSess { | |
| 1560 | + | setZotSessionEnvironment(r, nil) | |
| 1551 | 1561 | return nil, nil | |
| 1552 | 1562 | } | |
| 1553 | 1563 | // Sweep meta-only files left over from older zot versions (and from | |
| @@ -1605,9 +1615,31 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||
| 1605 | 1615 | } | |
| 1606 | 1616 | } | |
| 1607 | 1617 | bindAgentSession(ag, s) | |
| 1618 | + | setZotSessionEnvironment(r, s) | |
| 1608 | 1619 | return s, nil | |
| 1609 | 1620 | } | |
| 1610 | 1621 | ||
| 1622 | + | // setZotSessionEnvironment publishes the non-secret runtime metadata that | |
| 1623 | + | // zot's child processes can use for attribution and diagnostics. | |
| 1624 | + | func setZotSessionEnvironment(r Resolved, sess *core.Session) { | |
| 1625 | + | values := map[string]string{ | |
| 1626 | + | "ZOT_PROVIDER": r.Provider, | |
| 1627 | + | "ZOT_MODEL": r.Model, | |
| 1628 | + | "ZOT_REASONING_LEVEL": r.Reasoning, | |
| 1629 | + | } | |
| 1630 | + | if sess != nil { | |
| 1631 | + | values["ZOT_SESSION_ID"] = sess.ID | |
| 1632 | + | values["ZOT_SESSION_FILE"] = sess.Path | |
| 1633 | + | } | |
| 1634 | + | for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} { | |
| 1635 | + | if value := values[name]; value != "" { | |
| 1636 | + | _ = os.Setenv(name, value) | |
| 1637 | + | } else { | |
| 1638 | + | _ = os.Unsetenv(name) | |
| 1639 | + | } | |
| 1640 | + | } | |
| 1641 | + | } | |
| 1642 | + | ||
| 1611 | 1643 | func pickSession(root, cwd string) (string, error) { | |
| 1612 | 1644 | files := core.ListSessions(root, cwd) | |
| 1613 | 1645 | if len(files) == 0 { | |
| @@ -208,8 +208,13 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |||||
| 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 | 209 | // Mark the process and all child processes as running under zot. This | ||
| 210 | // lets shell tools, extensions, and scripts distinguish zot execution | 210 | // lets shell tools, extensions, and scripts distinguish zot execution | ||
| 211 | - | // from an ordinary invocation. | 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") | |||
| 212 | _ = os.Setenv("ZOT_AGENT", "1") | 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 | + | } | |||
| 213 | 218 | ||||
| 214 | // Apply network configuration before any subcommand can make an HTTP | 219 | // Apply network configuration before any subcommand can make an HTTP | ||
| 215 | // request. Standard proxy environment variables retain precedence. | 220 | // request. Standard proxy environment variables retain precedence. | ||
| @@ -1065,6 +1070,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1065 | sess = newSess | 1070 | sess = newSess | ||
| 1066 | currentAg.SetMessages(msgs) | 1071 | currentAg.SetMessages(msgs) | ||
| 1067 | bindAgentSession(currentAg, sess) | 1072 | bindAgentSession(currentAg, sess) | ||
| 1073 | + | setZotSessionEnvironment(r, sess) | |||
| 1068 | startExtensionSession(extMgr, currentAg, r.CWD, "session_switch") | 1074 | startExtensionSession(extMgr, currentAg, r.CWD, "session_switch") | ||
| 1069 | if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil { | 1075 | if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil { | ||
| 1070 | currentAg.SeedCost(cum) | 1076 | currentAg.SeedCost(cum) | ||
| @@ -1472,6 +1478,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1472 | if sess != nil { | 1478 | if sess != nil { | ||
| 1473 | _ = sess.UpdateModel(providerName, model) | 1479 | _ = sess.UpdateModel(providerName, model) | ||
| 1474 | } | 1480 | } | ||
| 1481 | + | r.Provider = providerName | |||
| 1482 | + | r.Model = model | |||
| 1483 | + | setZotSessionEnvironment(r, sess) | |||
| 1475 | }, | 1484 | }, | ||
| 1476 | }) | 1485 | }) | ||
| 1477 | 1486 | ||||
| @@ -1548,6 +1557,7 @@ func agentSessionsRoot(root string, args Args) string { | |||||
| 1548 | // exists, its id is bound onto ag so providers can sticky-route. | 1557 | // exists, its id is bound onto ag so providers can sticky-route. | ||
| 1549 | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | 1558 | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | ||
| 1550 | if args.NoSess { | 1559 | if args.NoSess { | ||
| 1560 | + | setZotSessionEnvironment(r, nil) | |||
| 1551 | return nil, nil | 1561 | return nil, nil | ||
| 1552 | } | 1562 | } | ||
| 1553 | // Sweep meta-only files left over from older zot versions (and from | 1563 | // Sweep meta-only files left over from older zot versions (and from | ||
| @@ -1605,9 +1615,31 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||||
| 1605 | } | 1615 | } | ||
| 1606 | } | 1616 | } | ||
| 1607 | bindAgentSession(ag, s) | 1617 | bindAgentSession(ag, s) | ||
| 1618 | + | setZotSessionEnvironment(r, s) | |||
| 1608 | return s, nil | 1619 | return s, nil | ||
| 1609 | } | 1620 | } | ||
| 1610 | 1621 | ||||
| 1622 | + | // setZotSessionEnvironment publishes the non-secret runtime metadata that | |||
| 1623 | + | // zot's child processes can use for attribution and diagnostics. | |||
| 1624 | + | func setZotSessionEnvironment(r Resolved, sess *core.Session) { | |||
| 1625 | + | values := map[string]string{ | |||
| 1626 | + | "ZOT_PROVIDER": r.Provider, | |||
| 1627 | + | "ZOT_MODEL": r.Model, | |||
| 1628 | + | "ZOT_REASONING_LEVEL": r.Reasoning, | |||
| 1629 | + | } | |||
| 1630 | + | if sess != nil { | |||
| 1631 | + | values["ZOT_SESSION_ID"] = sess.ID | |||
| 1632 | + | values["ZOT_SESSION_FILE"] = sess.Path | |||
| 1633 | + | } | |||
| 1634 | + | for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} { | |||
| 1635 | + | if value := values[name]; value != "" { | |||
| 1636 | + | _ = os.Setenv(name, value) | |||
| 1637 | + | } else { | |||
| 1638 | + | _ = os.Unsetenv(name) | |||
| 1639 | + | } | |||
| 1640 | + | } | |||
| 1641 | + | } | |||
| 1642 | + | ||||
| 1611 | func pickSession(root, cwd string) (string, error) { | 1643 | func pickSession(root, cwd string) (string, error) { | ||
| 1612 | files := core.ListSessions(root, cwd) | 1644 | files := core.ListSessions(root, cwd) | ||
| 1613 | if len(files) == 0 { | 1645 | if len(files) == 0 { | ||
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") | ||