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
31 changes: 30 additions & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,12 @@
collaborationController *serveCollaborativeShellController
// childRuns makes delegated runs readable and steerable. It is nil outside
// serve, which is what keeps the CLI spawn path unchanged.
childRuns *childRunRegistry

Check failure on line 311 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / frontend

other declaration of childRuns

Check failure on line 311 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / passkey-smoke

other declaration of childRuns

Check failure on line 311 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / cross-build

other declaration of childRuns

Check failure on line 311 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / race

other declaration of childRuns
hasWeb bool
jobsServerURL string
jobsServerToken string
childRuns *childRunRegistry

Check failure on line 315 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / frontend

childRuns redeclared

Check failure on line 315 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / passkey-smoke

childRuns redeclared

Check failure on line 315 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / cross-build

childRuns redeclared

Check failure on line 315 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / race

childRuns redeclared
agentOwner *agentHostOwner
hasWeb bool
}

func newServeAgentRuntimeFactory(opts serveAgentRuntimeOptions, server func() *serveServer) func(context.Context, serveRuntimeRequest) (*serveRuntime, error) {
Expand Down Expand Up @@ -380,6 +383,28 @@
return registry
}

func serveJobsToolURL(host string, port int, basePath string, mountedUnderBasePath bool) string {
if ip := net.ParseIP(host); ip != nil && ip.IsUnspecified() {
if ip.To4() != nil {
host = "127.0.0.1"
} else {
host = "::1"
}
}
baseURL := "http://" + net.JoinHostPort(host, strconv.Itoa(port))
if mountedUnderBasePath {
baseURL += basePath
}
return baseURL
}

func serveJobsToolClientConfig(hasJobs, hasWeb, hasAPI bool, host string, port int, basePath, token string) (string, string) {
if !hasJobs {
return "", ""
}
return serveJobsToolURL(host, port, basePath, hasWeb || hasAPI), token
}

func validateServeRuntimeToolMap(runtime *serveRuntime, toolMap map[string]string) error {
for clientName, serverName := range toolMap {
if _, ok := runtime.engine.Tools().Get(serverName); ok {
Expand Down Expand Up @@ -409,6 +434,9 @@
}
runtime.toolMgr.Registry.SetServeMode(true, imageBaseURL)
runtime.toolMgr.Registry.SetMediaPublisher(opts.mediaPublisher)
if opts.jobsServerURL != "" {
runtime.toolMgr.Registry.SetJobsServer(opts.jobsServerURL, opts.jobsServerToken)
}
if opts.hasWeb {
runtime.toolMgr.Registry.SetCollaborativeShellController(opts.collaborationController, tools.ShellRoutingControllerRequired)
} else {
Expand Down Expand Up @@ -634,6 +662,7 @@
toolMap: toolMap, mediaPublisher: mediaPublisher, collaborationController: collaborationController,
hasWeb: hasWeb,
}
runtimeOptions.jobsServerURL, runtimeOptions.jobsServerToken = serveJobsToolClientConfig(hasJobs, hasWeb, hasAPI, serveHost, servePort, serveBasePath, token)
agentRuntimeFactory := newServeAgentRuntimeFactory(runtimeOptions, func() *serveServer { return s })

runtimeFactory := func(ctx context.Context, request serveRuntimeRequest) (*serveRuntime, error) {
Expand Down
64 changes: 64 additions & 0 deletions cmd/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,70 @@ func TestServeHTTPHandler_MountsJobsOnlyAtRoot(t *testing.T) {
}
}

func TestQueueAgentCombinedWebJobsCreatesJob(t *testing.T) {
mgr := newJobsV2ManagerWithoutLoops(t)
srv := &serveServer{
cfg: serveServerConfig{ui: true, basePath: "/ui", requireAuth: true, token: "local-token"},
jobsV2: mgr,
}
server := httptest.NewServer(srv.httpHandler())
defer server.Close()

toolMgr, err := tools.NewToolManager(&tools.ToolConfig{Enabled: []string{tools.QueueAgentToolName, tools.WaitForJobsToolName}}, nil)
if err != nil {
t.Fatalf("NewToolManager: %v", err)
}
runtime := &serveRuntime{toolMgr: toolMgr}
configureServeRuntimeTools(runtime, serveAgentRuntimeOptions{
hasWeb: true, jobsServerURL: server.URL + "/ui", jobsServerToken: "local-token",
approval: resolvedApprovalMode{Mode: tools.ModeYolo},
})
queue, ok := toolMgr.Registry.Get(tools.QueueAgentToolName)
if !ok {
t.Fatal("queue_agent was not registered")
}
ctx := tools.ContextWithQueueAgentOrigin(context.Background(), tools.QueueAgentOriginContext{Origin: tools.QueueAgentOriginWeb, SessionID: "sess-origin"})
out, err := queue.Execute(ctx, json.RawMessage(`{"agent_name":"developer","prompt":"do it","cwd":"/tmp/work","notify_when_done":true}`))
if err != nil || out.IsError {
t.Fatalf("queue_agent: result=%+v err=%v", out, err)
}
var result tools.QueueAgentResult
if err := json.Unmarshal([]byte(out.Content), &result); err != nil {
t.Fatalf("decode queue result: %v", err)
}
job, err := mgr.GetJob(result.JobID)
if err != nil {
t.Fatalf("queued job was not created: %v", err)
}
var runnerConfig jobsV2LLMConfig
if err := json.Unmarshal(job.RunnerConfig, &runnerConfig); err != nil {
t.Fatalf("decode runner config: %v", err)
}
if runnerConfig.NotifyOrigin == nil || runnerConfig.NotifyOrigin.SessionID != "sess-origin" {
t.Fatalf("notify origin = %+v, want originating web session", runnerConfig.NotifyOrigin)
}
}

func TestServeJobsToolURL(t *testing.T) {
for _, test := range []struct {
name, host, basePath, want string
underBasePath bool
}{
{name: "combined", host: "127.0.0.1", basePath: "/ui", underBasePath: true, want: "http://127.0.0.1:8080/ui"},
{name: "jobs only", host: "127.0.0.1", basePath: "/ui", want: "http://127.0.0.1:8080"},
{name: "wildcard bind", host: "0.0.0.0", basePath: "/chat", underBasePath: true, want: "http://127.0.0.1:8080/chat"},
} {
t.Run(test.name, func(t *testing.T) {
if got := serveJobsToolURL(test.host, 8080, test.basePath, test.underBasePath); got != test.want {
t.Fatalf("serveJobsToolURL = %q, want %q", got, test.want)
}
})
}
if baseURL, token := serveJobsToolClientConfig(false, true, false, "127.0.0.1", 8080, "/ui", "secret"); baseURL != "" || token != "" {
t.Fatalf("web-only client config = (%q, %q), want empty", baseURL, token)
}
}

func TestServeCORSExposesUIVersionHeader(t *testing.T) {
srv := &serveServer{
cfg: serveServerConfig{
Expand Down
2 changes: 2 additions & 0 deletions docs-site/content/guides/job-runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ term-llm jobs run cancel run_abc123

`jobs runs` defaults to 50 rows, and `jobs run events` defaults to 200; both support `--limit` and `--offset`. With combined `serve web jobs`, include the web base path (normally `/ui`) in `TERM_LLM_JOBS_SERVER`; standalone `serve jobs` exposes these routes at the root.

When `queue_agent` or `wait_for_jobs` runs inside `serve web jobs`, it uses that server's jobs API and bearer token automatically. This keeps `notify_when_done` connected to the originating web session. Outside a combined serve process, these tools use `TERM_LLM_JOBS_SERVER` and `TERM_LLM_JOBS_TOKEN`. Set the server URL to the exact API base: include `/ui` (or a custom `--base-path`) for combined serve, and omit it for standalone `serve jobs`.

### Trigger Types

- `manual`: run only when manually triggered
Expand Down
9 changes: 6 additions & 3 deletions internal/tools/queue_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,10 +380,13 @@ func newJobsBackedAgentClientFromEnv() *jobsBackedAgentClient {
if baseURL == "" {
baseURL = defaultJobsServerBaseURL
}
baseURL = strings.TrimRight(strings.TrimSuffix(strings.TrimSuffix(baseURL, "/ui"), "/chat"), "/")
return newJobsBackedAgentClient(baseURL, strings.TrimSpace(os.Getenv("TERM_LLM_JOBS_TOKEN")))
}

func newJobsBackedAgentClient(baseURL, token string) *jobsBackedAgentClient {
return &jobsBackedAgentClient{
baseURL: baseURL,
token: strings.TrimSpace(os.Getenv("TERM_LLM_JOBS_TOKEN")),
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
Expand Down
44 changes: 44 additions & 0 deletions internal/tools/queue_agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,50 @@ func TestQueueAgentCreatesAndTriggersJobsBackedLLMJob(t *testing.T) {
}
}

func TestQueueAgentUsesConfiguredJobsBasePath(t *testing.T) {
var created bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/ui/v2/jobs":
created = true
writeJSON(t, w, jobsV2AgentJobResponse{ID: "job_123"})
case "/ui/v2/jobs/job_123/trigger":
writeJSON(t, w, jobsV2AgentRunResponse{ID: "run_123", JobID: "job_123"})
default:
http.Redirect(w, r, "/ui/", http.StatusTemporaryRedirect)
}
}))
defer server.Close()
t.Setenv("TERM_LLM_JOBS_SERVER", server.URL+"/ui")

out, err := NewQueueAgentTool().Execute(context.Background(), json.RawMessage(`{"agent_name":"developer","prompt":"do it","cwd":"/tmp/work"}`))
if err != nil || out.IsError || !created {
t.Fatalf("queue against combined jobs route: created=%v result=%+v err=%v", created, out, err)
}
}

func TestWaitForJobsUsesConfiguredJobsBasePath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/v2/runs/run_123" {
t.Errorf("request path = %q, want /chat/v2/runs/run_123", r.URL.Path)
http.NotFound(w, r)
return
}
writeJSON(t, w, jobsV2AgentRunResponse{ID: "run_123", JobID: "job_123", Status: "succeeded", Response: "done"})
}))
defer server.Close()
t.Setenv("TERM_LLM_JOBS_SERVER", server.URL+"/chat")

out, err := NewWaitForJobsTool().Execute(context.Background(), json.RawMessage(`{"run_ids":["run_123"]}`))
if err != nil || out.IsError {
t.Fatalf("wait_for_jobs: result=%+v err=%v", out, err)
}
var results []QueuedJobResult
if err := json.Unmarshal([]byte(out.Content), &results); err != nil || len(results) != 1 || results[0].Status != "succeeded" {
t.Fatalf("wait_for_jobs result = %+v, decode error = %v", results, err)
}
}

func TestQueueAgentRecoversRunAfterLostTriggerResponse(t *testing.T) {
for _, test := range []struct {
name string
Expand Down
14 changes: 14 additions & 0 deletions internal/tools/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,20 @@ func (r *LocalToolRegistry) registerTool(specName string) error {
return nil
}

// SetJobsServer binds queued-job tools to the jobs API hosted by this serve process.
// Other runtimes retain their environment-configured jobs client.
func (r *LocalToolRegistry) SetJobsServer(baseURL, token string) {
r.mu.Lock()
defer r.mu.Unlock()
client := newJobsBackedAgentClient(baseURL, token)
if tool, ok := r.tools[QueueAgentToolName].(*QueueAgentTool); ok {
tool.client = client
}
if tool, ok := r.tools[WaitForJobsToolName].(*WaitForJobsTool); ok {
tool.client = client
}
}

// newSessionStateTool constructs non-filesystem, session-bound state controls.
func (r *LocalToolRegistry) newSessionStateTool(name string) llm.Tool {
if name == LiveSettingsToolName {
Expand Down
Loading