Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/concurrency.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,13 @@ Shell and kernel subprocesses run in managed process groups. This is
operational containment, not a security sandbox against another hostile
process already running as the same OS user.

Managed host processes inherit the Go build settings `GOCACHE`, `GOMODCACHE`,
`GOPATH`, `GOROOT`, `GOTOOLCHAIN`, and `GOFLAGS` captured when the process manager
starts. This preserves preloaded modules and toolchains in offline environments.
Explicit child-process settings take precedence over that snapshot. Other Go
variables, including credential-bearing proxy/auth settings, are not implicitly
inherited; the allowlist is in `internal/capability/process.go`.

## Gateway process ownership

The socket-only daemon owns execution; `internal/webgateway` owns HTTP/WS
Expand Down
26 changes: 25 additions & 1 deletion internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1223,8 +1223,32 @@ func (a *Agent) ManualCompact(ctx context.Context, ev Events) error {

// finalAnswer makes one last completion with tools disabled, so a run that hit
// the tool-turn cap still returns the model's best answer instead of an error.
// A system nudge tells the model to stop calling tools and answer now.
// The capped answer goes through the same final-message check the loop runs
// on a no-tools reply: a definition's output contract is a property of the
// turn, not of how it ended. One correction round is allowed, as in the loop;
// a second miss fails the turn.
func (a *Agent) finalAnswer(ctx context.Context, ev Events) (string, error) {
text, err := a.cappedAnswer(ctx, ev)
if err != nil || ev.CheckFinal == nil {
return text, err
}
retry, err := ev.CheckFinal(text)
if err != nil || !retry {
return text, err
}
if text, err = a.cappedAnswer(ctx, ev); err != nil {
return text, err
}
if _, err := ev.CheckFinal(text); err != nil {
return "", err
}
return text, nil
}

// cappedAnswer is one tools-disabled completion. A system nudge tells the
// model to stop calling tools and answer now; the turn's ephemeral notices
// (an output-contract correction among them) ride along as usual.
func (a *Agent) cappedAnswer(ctx context.Context, ev Events) (string, error) {
msgs := append(slices.Clone(withEphemeralSystem(a.Messages, ev.ephemeral())),
llm.Message{Role: "system", Content: "You have reached the tool-call limit. Do NOT request any more tools. Give your final answer now using only what you have already gathered."})
client := *a.Client
Expand Down
118 changes: 118 additions & 0 deletions internal/agent/final_answer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package agent

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/context-labs/whip/internal/llm"
"github.com/context-labs/whip/internal/tools"
)

// A turn that hits the cap ends with a tools-disabled answer. That answer must
// pass the same final-message check a no-tools reply does, with the one
// correction round the check may ask for.
func TestFinalAnswerRunsCheckFinalWithOneCorrection(t *testing.T) {
t.Parallel()
var finals []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request llm.Request
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Error(err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
switch {
case len(request.Tools) > 0:
fmt.Fprint(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"echo","arguments":"{\"s\":\"work\"}"}}]}}]}`+"\n\n")
default:
// The correction notice rides as an ephemeral system message
// ahead of the tools-disabled nudge.
var system []string
for _, message := range request.Messages {
if message.Role == "system" {
system = append(system, message.Content)
}
}
joined := strings.Join(system, "\n")
finals = append(finals, joined)
answer := "not json"
if strings.Contains(joined, "correct") {
answer = `{"ok":true}`
}
fmt.Fprintf(w, `data: {"choices":[{"delta":{"content":%q},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}`+"\n\n", answer)
}
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer server.Close()
ag := NewRuntime(llm.New(server.URL, "key"), "model", 1024, "system", tools.NewServices())
defer ag.Services.Close()
ag.Tools = []tools.Tool{echoTool()}
ag.MaxTurns = 1
var notice string
checks := 0
ev := Events{
EphemeralNotices: func() string { return notice },
CheckFinal: func(text string) (bool, error) {
checks++
if text == `{"ok":true}` {
return false, nil
}
if checks > 1 {
return false, fmt.Errorf("output_invalid: %q", text)
}
notice = "please correct: reply with one JSON value"
return true, nil
},
}
final, err := ag.Turn(t.Context(), "go", ev)
if err != nil || final != `{"ok":true}` {
t.Fatalf("capped turn = %q, %v", final, err)
}
if checks != 2 || len(finals) != 2 {
t.Fatalf("checks=%d final calls=%d", checks, len(finals))
}
if strings.Contains(finals[0], "correct") || !strings.Contains(finals[1], "correct") {
t.Fatalf("correction notice not delivered on the second final call: %q", finals)
}
}

func TestFinalAnswerFailsTheTurnOnASecondMiss(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request llm.Request
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Error(err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
if len(request.Tools) > 0 {
fmt.Fprint(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"echo","arguments":"{\"s\":\"work\"}"}}]}}]}`+"\n\n")
} else {
fmt.Fprint(w, `data: {"choices":[{"delta":{"content":"still not json"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}`+"\n\n")
}
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer server.Close()
ag := NewRuntime(llm.New(server.URL, "key"), "model", 1024, "system", tools.NewServices())
defer ag.Services.Close()
ag.Tools = []tools.Tool{echoTool()}
ag.MaxTurns = 1
checks := 0
_, err := ag.Turn(t.Context(), "go", Events{CheckFinal: func(string) (bool, error) {
checks++
if checks > 1 {
return false, errors.New("output_invalid")
}
return true, nil
}})
if err == nil || !strings.Contains(err.Error(), "output_invalid") || checks != 2 {
t.Fatalf("second miss should fail the turn: err=%v checks=%d", err, checks)
}
}
4 changes: 4 additions & 0 deletions internal/capability/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,10 @@ func allowedBaseEnvironment(name string) bool {
"TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE",
"TERM", "COLORTERM", "NO_COLOR":
return true
case "GOCACHE", "GOMODCACHE", "GOPATH", "GOROOT", "GOTOOLCHAIN", "GOFLAGS":
// Keep the host's Go toolchain and preloaded caches usable offline.
// Do not allow GO* wholesale: proxy/auth settings can carry credentials.
return true
}
return name == buildinfo.Env("HOME") || strings.HasPrefix(name, "LC_")
}
Expand Down
65 changes: 65 additions & 0 deletions internal/capability/process_go_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//go:build darwin || linux

package capability

import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)

func TestProcessGoUsesPreloadedModuleCacheOffline(t *testing.T) {
goBin, err := exec.LookPath("go")
if err != nil {
t.Skip("Go executable is not on PATH")
}
work := t.TempDir()
cache := t.TempDir()
const dependency = "example.com/whip-cache-test"
// Listing the module graph needs cached metadata, so this fixture exercises
// a real Go command without downloads or toolchain compilation.
mod := filepath.Join(cache, "cache", "download", dependency, "@v", "v1.0.0.mod")
if err := os.MkdirAll(filepath.Dir(mod), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(mod, []byte("module "+dependency+"\n\ngo 1.20\n"), 0o644); err != nil {
t.Fatal(err)
}
info := filepath.Join(filepath.Dir(mod), "v1.0.0.info")
if err := os.WriteFile(info, []byte(`{"Version":"v1.0.0","Time":"2020-01-01T00:00:00Z"}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(work, "go.mod"), []byte("module example.com/app\n\ngo 1.20\n\nrequire "+dependency+" v1.0.0\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("GOMODCACHE", cache)
t.Setenv("GOCACHE", t.TempDir())
t.Setenv("GOPATH", t.TempDir())
t.Setenv("GOTOOLCHAIN", "local")
t.Setenv("GOFLAGS", "-mod=mod")
m := NewProcessManager()
t.Cleanup(func() { _ = m.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var stdout, stderr bytes.Buffer
p, err := m.Start(ctx, "root", goBin, []string{"list", "-m", "all"}, ProcessOptions{
Cwd: work,
// Explicitly prohibit downloads and ignore host-specific Go settings.
Env: map[string]string{"GOPROXY": "off", "GOSUMDB": "off", "GOENV": "off", "GOWORK": "off"},
Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr,
})
if err != nil {
t.Fatal(err)
}
if err := p.Wait(); err != nil {
t.Fatalf("offline module lookup failed: %v\n%s", err, stderr.String())
}
if !strings.Contains(stdout.String(), dependency+" v1.0.0\n") {
t.Fatalf("cached dependency missing from module graph: %s", stdout.String())
}
}
37 changes: 36 additions & 1 deletion internal/capability/process_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,23 @@ func TestProcessEnvironmentCwdAndDescriptors(t *testing.T) {
t.Setenv("SSH_AUTH_SOCK", "/daemon-agent")
t.Setenv("RANDOM_DAEMON_SECRET", "daemon-random")
t.Setenv("WHIPCODE_HOME", "/snapshot-whip-home")
goEnvironment := map[string]string{
"GOCACHE": "/snapshot-go-build-cache",
"GOMODCACHE": "/snapshot-go-module-cache",
"GOPATH": "/snapshot-gopath",
"GOROOT": "/snapshot-goroot",
"GOTOOLCHAIN": "local",
"GOFLAGS": "-mod=mod",
}
for name, value := range goEnvironment {
t.Setenv(name, value)
}
t.Setenv("GOPROXY", "https://user:secret@proxy.invalid")
t.Setenv("GOAUTH", "daemon-auth-command")
t.Setenv("GO_PRIVATE_TOKEN", "daemon-go-secret")
m := NewProcessManager()
t.Setenv("HOME", "/changed-after-snapshot")
t.Setenv("GOMODCACHE", "/changed-after-snapshot")

f, err := os.CreateTemp(t.TempDir(), "descriptor")
if err != nil {
Expand Down Expand Up @@ -112,7 +127,12 @@ func TestProcessEnvironmentCwdAndDescriptors(t *testing.T) {
t.Errorf("%s = %q, want %q", name, env[name], want)
}
}
for _, name := range []string{"PROVIDER_API_KEY", "SSH_AUTH_SOCK", "RANDOM_DAEMON_SECRET"} {
for name, want := range goEnvironment {
if env[name] != want {
t.Errorf("%s = %q, want %q", name, env[name], want)
}
}
for _, name := range []string{"PROVIDER_API_KEY", "SSH_AUTH_SOCK", "RANDOM_DAEMON_SECRET", "GOPROXY", "GOAUTH", "GO_PRIVATE_TOKEN"} {
if _, ok := env[name]; ok {
t.Errorf("daemon environment leaked %s", name)
}
Expand All @@ -130,6 +150,21 @@ func TestProcessEnvironmentCwdAndDescriptors(t *testing.T) {
if built, err := m.environment(nil); err != nil || !slices.IsSorted(built) {
t.Errorf("environment not deterministically sorted: %v, %v", built, err)
}
// Launchers that own exec.Cmd receive the captured build settings too;
// explicit per-process values still take precedence.
child, err := m.ChildEnvironment(map[string]string{"GOFLAGS": "-mod=readonly"})
if err != nil {
t.Fatal(err)
}
childEnv := envMap(child)
for name, want := range goEnvironment {
if name == "GOFLAGS" {
want = "-mod=readonly"
}
if childEnv[name] != want {
t.Errorf("ChildEnvironment %s = %q, want %q", name, childEnv[name], want)
}
}
if err := m.Close(); err != nil {
t.Fatal(err)
}
Expand Down
Loading