Skip to content

Open Harness Server subcommands - #1853

Merged
SSharma-10 merged 241 commits into
mainfrom
feat/agents-subcommands
Sep 22, 2026
Merged

SSharma-10 merged 241 commits into
mainfrom
feat/agents-subcommands

Conversation

@SSharma-10

Copy link
Copy Markdown
Contributor

No description provided.

Comment thread commands/agents.go Outdated
Comment thread commands/agents.go Outdated

@logwolvy logwolvy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming, relevant godo changes for sending agent spec yaml are done.
Please address the comments and we should be good to merge.

Comment thread commands/agents.go Outdated
Comment thread commands/agents.go Outdated
Comment thread commands/agents.go Outdated
SSharma-10 and others added 6 commits June 3, 2026 16:52
doctl agent attach connected but printed nothing: the harness-api SSE
stream emits the SPI canonical event envelope (type/data/timestamp/
tenant_id, dot-separated event names) while vendored godo decoded
kind/payload/at/team_id, so every event fell through renderEvent.

- godo HostedAgentEvent: decode the SPI wire via UnmarshalJSON and switch
  the HostedAgentEventKind constants to the dot-separated SPI names.
- godo ErrorResponse: also read the nested {"error":{code,message}}
  envelope so failed calls show the server's reason, not a bare status.
- agents: align event payload structs/rendering and HITL tracking to the
  SPI data shapes, and acknowledge each submit with its run id so the
  agent's startup latency isn't mistaken for a hang (which caused
  duplicate submits).
- tests for the wire decode, rendering, nested-error surfacing, and ack.

The vendored godo edits mirror the fix being upstreamed to
digitalocean/godo and are in-tree until godo is re-vendored.

Co-authored-by: Cursor <cursoragent@cursor.com>
logwolvy
logwolvy previously approved these changes Jun 8, 2026

@logwolvy logwolvy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will be raising a PR for stream related fixes on doctl and godo.

Comment thread internal/deviceid/deviceid_darwin.go
Comment thread internal/deviceid/deviceid_windows.go
Comment thread commands/agents_test.go
@@ -0,0 +1,1215 @@
/*
Copyright 2026 The Doctl Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apple Pay

Vendors glamour and its transitive deps (chroma, goldmark, bluemonday,
etc.) and bumps lipgloss/termenv so `doctl agents` can render the
agent's markdown replies with syntax-highlighted code blocks. Also
re-syncs the pinned godo vendor tree (adds Session.SandboxID).

Co-authored-by: Cursor <cursoragent@cursor.com>
SSharma-10 and others added 26 commits September 17, 2026 15:09
command_execution-style approvals (bash, GitHub writes) already worked as a
plain approve/reject/defer verdict. Agents now also emit mcp_elicitation HITLs
via harness-api: an OAuth url elicitation, an empty-schema yes/no tool-call
approval, and a schema-bearing data form — none of which this CLI could
render or answer, and the resolve request had nowhere to carry the form's
answer content (only a verdict).

- commands/agents_hitl_elicitation.go: classify a HITLRequested payload's
  shape (command / url / approval / form), parse requestedSchema into
  validated fields (the MCP spec restricts these to flat primitives), and
  render the message/URL/schema card wherever a command's summary line
  showed before.
- Interactive attach TTY: url/approval reuse the existing y/n/d menu
  (content stays empty); a schema-bearing form gets its own field-by-field
  prompt (attachState.form), reusing the plain line editor; the ticket's
  {approve: boolean} special case gets a two-key Approve/Deny prompt where
  both keys resolve Outcome: Approve and differ only in Content.
- Headless paths (--on-hitl, `prompt`) fail loudly on a data form instead of
  hanging or submitting an empty answer — there's no value to synthesize.
- `approve` (out-of-band resolve) gains --content, a JSON object forwarded
  as-is, following the --user-simulator-config pattern.

## Paired with digitalocean/godo#1115 — review together

The godo half is digitalocean/godo#1115, open for review in parallel with
this one. It targets OHS_endpoints rather than main, since hosted_agents.go
only exists on that branch.

The vendor/github.com/digitalocean/godo/hosted_agents.go change here is
byte-identical to what godo#1115 ships (just the Content field on
HostedAgentResolveHITLRequest) — it's here only so doctl compiles and can be
reviewed now, not as a permanent fork of the vendored library. go.mod is
untouched; it still points at the released v1.208.0-beta.1.

Merge order: godo#1115 first, then a release, then this PR swaps the
hand-edit for a bump (go get github.com/digitalocean/godo@<new-version> &&
go mod vendor), which should regenerate the same file and collapse the vendor
diff to nothing but the version string in go.mod/go.sum/vendor/modules.txt.

Follows the precedent set by digitalocean/godo#1112 / #1969.
Swaps the hand-patched vendor/hosted_agents.go for a real dependency bump now
that HostedAgentResolveHITLRequest.Content shipped in a tagged godo beta. The
vendored file's diff collapses to nothing but the version metadata, as
expected — the hand-patch was byte-identical to the released field.
harness-runtime: render & answer MCP elicitation HITLs (MARSOHS-1443)
added hermes-base to template base allowList
Harness Runtime sessions are paid from a team-wide prepayment balance.
When it runs out, harness-api returns 402 on every spend route, but doctl
rendered that as a generic "Request failed" with no mention of billing --
and an attached session simply went quiet mid-sentence.

Adds a `balance` command, turns the gate's 402 into an add-funds card
enriched with the live balance, and makes an unasked-for pause explain
itself.

- do/prepayment.go reads GET /v2/customers/my/prepayment_config, which
  returns config and status together so one request covers every
  messaging branch. A 403 becomes ErrNoBillingPermission: the token
  lacks billing:read, which is a state to report rather than a failure.
- The 402 card is built centrally in beautifyAgentErrorFor, so all
  twelve gated routes are covered without call sites opting in.
  port-forward is wired directly because it sits outside the pretty-error
  wrapper. Billing is read only on the 402 path and by `balance`, so the
  non-spend commands stay ungated and keep working at $0.
- The balance read that enriches the card is bounded to 5s, which is why
  PrepaymentService.Get takes a context rather than using the do/*
  package-wide context.TODO(). The user has already earned the 402, so a
  degraded billing API must not hold it back on doctl's usual retry
  schedule; on expiry the card falls back to its plain wording.
- 503 gets its own card. The gate fails closed when it cannot resolve a
  team's mirrored state, and telling a solvent user to add funds because
  a lookup timed out would be actively misleading.
- Mid-session, the card prints inline and the session stays attached:
  the work is intact and the next prompt succeeds once funds land. The
  typed message is restored, and the card is latched to once per gate
  episode so a retry loop does not wallpaper the terminal.

Messaging leads with the team-level cause rather than the failed verb,
since the same card appears on twelve commands, and says the work is
saved. Zero-balance messaging ships the depleted wording only: the
never-funded variant would key off config.created_at, but that marks
whether a team configured auto top-off, not whether it ever funded, so a
one-time top-up that drained would be misread as a brand new account.
A session paused by the prepay gate looked identical to one paused for
being idle: status PAUSED, no explanation. harness-api has told us the
difference for a while via pause_reason, but godo's HostedAgentSession
had no such field, so the value was discarded during its unmarshal and
never reached doctl.

The field is added to godo rather than worked around here, as
digitalocean/godo#1113. The vendored hosted_agents.go change in this
commit is byte-identical to what that PR ships -- it is here only so
doctl compiles and can be reviewed now, not as a permanent fork of the
vendored library. Once godo#1113 merges and releases, the hand-edit is
replaced by a pin bump and the vendor diff collapses to the version
strings.

- `show` and `list` name the reason inline, passed through verbatim so a
  reason the API adds later is displayed rather than flattened to
  "unknown". godo types it as a string for the same reason, with
  constants only for the three values documented today. The table column
  is only carried when some session actually has a reason, so an
  ordinary list does not grow a blank column.
- A low_balance pause points at the balance before `launch`, since
  launching would just reproduce the 402 until funds land.
- `--paused-by` filters on the reason. There is no server-side
  pause-reason filter, so it implies status=paused on the request and
  filters the page locally; the help text says that interacts with
  pagination. Pairing it with a non-paused status is rejected rather
  than silently matching nothing.

Because the field is on godo's type, the session read paths stay on
godo's client and -o json keeps unwrapping to the godo struct, so
pause_reason reaches scripted output the same way sandbox_id and
resume_on_topoff already do. Nothing in do/ changes.
`--harness codex` built a manifest with `agent: codex-agentapi`, so a user
asking for the Codex CLI silently got OpenAI's sandbox-provider adapter
instead. They are two different adapters, not two spellings of one: `codex`
is the Codex CLI supervised by OHR in the coding-codex template, where
DigitalOcean runs the agent loop, while `codex-agentapi` keeps the loop and
session state at OpenAI and DigitalOcean supplies only the sandbox. The two
also differ in what they support -- codex-agentapi is excluded from
checkpoint, fork, and rollback.

`--harness codex` now yields `agent: codex` with an OPENAI_API_KEY secret
slot, no `config` block, and no CODEX_* env. The prompt goes out post-ready
via sendInitialPrompt, as it does for opencode and claude-code, rather than
riding in `config.input`. `codex-agentapi` and `openai-codex` keep producing
the full OpenAI body unchanged.

The OPENAI_API_KEY slot matches the coding-codex template, which runs
brightstaff with `credentials: backend: env` and regenerates .codex/auth.json
per run from the guest inference env; OPENAI_API_KEY is the value that set
takes first. A manifest can use the HARNESS_INFERENCE_* set instead, which is
why this is only what --harness writes, not the only thing that works.

Both `create` and `launch` share addAgentCreationFlags, so both are fixed
here. knownAgentAdapters already accepted both slugs, and
prepareOpenAISandboxStart already gates on the adapter, so `agent: codex`
creates no OpenAI session and expands no ${ENV_ID} / ${REMOTE_URL}; tests now
pin that.
agents: prepay billing awareness for harness-runtime
Resolve prepayment conflicts by keeping main's godo-backed
GetConfig/GetStatus and adding harness-runtime's context-aware Get
(with ErrNoBillingPermission). Pin godo to v1.210.0-beta.1 for
pause_reason and related hosted-agents APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Create/launch unit tests were failing because the T&C notice called
Account.Get (and would ListSessions) without gomock expectations.

Co-authored-by: Cursor <cursoragent@cursor.com>
The prepay gate announces a pause on session.updated as well as run.paused,
and often only there: run.paused needs a run to pause, so a session stopped
between turns -- or one sitting idle waiting for a prompt -- has no run event
to carry the reason. doctl rendered that event as a bare "session updated"
and, during warm-up, folded it into the banner and dropped it altogether. The
balance card added in 299856d was reachable from only one of the two paths
the gate actually uses.

What the user saw was a half-finished sentence, one muted line, four
"Reconnecting...", and "Failed to reconnect to agent activity stream." -- a
network diagnosis for a billing problem, with no mention of the balance that
would fix it.

- sessionUpdatedPayload parses status/pause_reason/changed_fields, accepting
  both the stream's short "paused" and the model's SESSION_STATUS_PAUSED.
  changed_fields is what keeps the card from reprinting: every update while
  paused still carries status=paused, so only one that changed status or
  pause_reason is news. The field is optional, and a paused status is then
  all we have -- announcing is the safer default there, since a repeated card
  is noise but a silent pause is the bug this fixes.
- The card is identical whichever event carried the reason. The user is
  looking at one stalled agent, and the run/session distinction that matters
  to the API is not one they can act on differently.
- drainStream now reports what the connection learned about pausing, so the
  reconnect loop can explain the silence instead of blaming the connection.
  Knowing nothing is kept distinct from knowing the session is not paused: a
  reconnect that drops before delivering an event carries no news, and
  collapsing the two would make a paused session look healthy again after one
  quiet retry. Reconnecting continues either way, because a top-off in another
  window brings the session back and the stream with it.
- The give-up message stays a fixed string rather than the balance-enriched
  card. It runs on the stream goroutine, where a billing round-trip would
  stall the one message that explains the silence; `balance` has the number.

Two smaller fixes fall out of routing a pause through one path: it now stops
the spinner, which was left turning over a session that had stopped, and a
gate pause reported on both run.paused and session.updated prints one card
rather than two.
attachToSession previously opened a single connection whose catching_up
(history) and live phases shared the wire, spliced apart server-side, with
stdin read from the moment the goroutine was launched -- before any of that
history had rendered.

ui-aquarium's /events consumer (useSessionHistoryPager + useStreamSession)
instead does this as two sequential calls: a finite replay_only history read
first, then a live attach anchored strictly after that read's newest event,
with input held until both settle. Give doctl the same shape:
replayHistoryBeforeAttach reads history to completion synchronously, on the
same goroutine that calls streamWithReconnect/runAttach right after, so:

  - stdin is never read until history has fully rendered, closing off
    MARSOHS-1026's user-visible symptom (a resent/duplicate turn rendering
    live, indistinguishable from new output, interleaved with whatever the
    user just typed) as a client-side defense independent of OHP's own
    ingest-side dedupe.
  - the live stream opens with ReplayFrom already seeded to the last history
    event's id (drainStream's existing cursor.set(ev.EventID) does this for
    free), so there is no overlap window left for OHP to splice.

Best-effort: a terminal error is reported once and returns, matching
streamWithReconnect's own classifyStreamError handling; a transient error
falls through silently to the live stream's own catching_up window, which is
the pre-existing behavior for that failure mode.
…split

agents: stop mapping --harness codex onto the codex-agentapi adapter
…stream

harness-runtime: explain a stream that stops on a prepay pause
…seam

harness-runtime: replay history before opening the live attach
Every harness-runtime verb that registers a displayer advertises --format and
--no-header in its own --help, but the runners only consulted Output == "json"
before choosing a renderer. In text mode the styled card was printed regardless,
so `checkpoint list --format CheckpointID,Label` returned the card and
`SID=$(... show <session> --format SessionID --no-header)` captured the whole
rendered box, borders and newlines included. The command still exited 0, so the
first sign of trouble was a malformed URL or a mis-parsed field downstream.

Route the choice through one predicate, agentStructuredOutput, which is true for
-o json and for an explicit --format / --no-header. The default output of every
verb is unchanged; only a caller who named columns gets them, and a bad column
name now fails at the doctl call instead of being ignored.

Decoration that shared stdout with the card follows the same rule: the page
token, config create advisories, the discovered-manifest notice, and create's
progress spinner move aside (to stderr, or off) when stdout is a table someone
is about to parse. exec, files cat, and prompt keep passing their payload
through byte for byte by default, and reach for the displayer only when asked.

The one verb that cannot answer with columns alone is triggers create, whose
one-time webhook secret is not one of the trigger's columns: under --format the
row goes to stdout and the secret to stderr, so it is neither captured nor lost.
Session update landed on feat/agents-subcommands after this change and
still keyed off Output == "json", so --format/--no-header would have
kept printing the card.

Co-authored-by: Cursor <cursoragent@cursor.com>
…-flags

harness-runtime: honor --format and --no-header, not just -o json
Co-authored-by: Cursor <cursoragent@cursor.com>
@SSharma-10
SSharma-10 merged commit f4e7d17 into main Sep 22, 2026
9 checks passed
@SSharma-10
SSharma-10 deleted the feat/agents-subcommands branch September 22, 2026 03:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.