qwen-code/packages/sdk-java/qwencode
Shaojin Wen c5098a2733
feat(managed-agent): H4 workflow child runtime (#13858)
* feat(managed-agent): H4 workflow child runtime (#13803)

Issue #13803's four exit checks land in one change because the
`managed-child_run` kind gate fires on every revision commitment: the
relay and cascade verbs on a workflow row would answer 409 until the
gate admits the kind, so the query widening (K2) and the gate flip
(K4) cannot be separated.

K1 — the ChildResultRelay discovery now selects `workflow` rows, and
creation branches on the record kind: a workflow launch mints exactly
one child Session under the parent's Workspace binding and lineage,
whose first Turn carries a `workflow_launch` block (pinned definition,
script, args) instead of a prompt. K3 — every produced launch pins
its definition from revision 1: the pin's digest is the sha256 of the
exact script bytes (revision 1 by convention, id `workflow/<name>`),
and the child's first Turn re-verifies the digest before spending
anything.

The daemon gains a `HOSTED_WORKFLOW_TOOL` declaration that mirrors
the Agent tool's admission behind its own kind reader; a workflow
child Session runs its first Turn deterministically in-process via
WorkflowRunner (its agent() calls are in-process AgentHeadless
subagents under ordinary caps, never nested managed grandchildren).
Wake, close, and consumption predicates treat both child Session
kinds through one funnel; quotas already count the two kinds
together.

One cross-language contract consequence: a workflow launch block is
the first prompt ever to carry fractional `args` across the daemon's
`sha256(JSON.stringify(prompt))` admission digest, so the SDK's JSON
digest substrate is canonicalized to ECMAScript byte-fidelity —
otherwise a fractional arg wedged the first Turn with a
deterministic `invalid_hosted_prompt`.

Physical acceptance in #13532's shape ran against the packaged stack:
end-to-end foreground and background workflow children with result
delivery verified on a real host; workspace-close cancellation is
blocked-with-reason on macOS (host capability, re-confirmed on the
final tree); a tampered pin refuses execution; the mid-interruption
re-claim path is partially observed and filed as issue #13838.

* fix(cli): refuse nested saved-workflow resolution outside the pinned slice (#13803)

The managed workflow child pins its script from the launch, but a
model-authored script's own workflow() call resolved saved workflows
the pin never examined, including bytes from the daemon host's own
~/.qwen/workflows (user tier). Admission now refuses any script whose
masked source invokes the injected workflow global, with the same
model-correctable text that names name, scriptPath, resumeFromRunId
and sourceRef as the deferred reference scope. The hosted runner
passage also passes restrictNestedScriptPaths as a second line, and
the declaration no longer advertises workflow() in this profile.

Witnesses: both workflow('x') and workflow({scriptPath}) forms refuse
without committing a child_run, the same text inside strings or
comments admits, and removing the admission gate turns both refusals
red.

* fix(sdk-java): render big numbers through the double the script peer holds (#13803)

The canonical writer computed its 2^53 exact-integer guard on a
value already narrowed through longValue(): Long.MIN_VALUE stayed
negative under Math.abs, and any BigInteger at or past 2^63 was
truncated -- sometimes sign-flipped -- before the check, printing
digits JSON.stringify never produces while both sides digest the
same corrupted text and therefore agree. Long and BigInteger now
flow through the shared double path, and the BigDecimal arm renders
through the double the parser could hold except in the subnormal
band, where the exact digits are the ECMAScript short form ('5e-324')
that a blunt double round-trip would print as '4.9e-324'.

Golden vectors pin Long.MIN_VALUE, 2^63, 2^64 and the decimal
examples (12345678901234567890.5 prints 12345678901234567000;
1E-400 prints 0; 1E+400 is refused); a 38-case parity probe matches
Node's JSON.stringify byte-for-byte, and removing each guard turns
the named witnesses red.

* fix(cli): close the review's pin-, admission-, preview- and doc-record gaps across the workflow child lane (#13803)

* fix(cli): adopt main's mount-refusal taxonomy in the workflow child admission path (#13803)

* fix(integration-tests): teach the hosted workspace tool-turn fixture the H4c workflow advertisement (#13803)

* fix(cli): harden the workflow child runtime lane against the review's round-1 findings (#13803)

Real-stack verification round 1 found the lexical admission's nested
workflow scan bypassable by indirection, and the foreground workflow
wait lost to any Harness restart. This change closes both:

- WorkflowRunner gains refuseNestedWorkflows: when set, every nested
  workflow() resolution — by name or by path, aliased or through the
  global — is refused, since any of them resolves bytes the launch pin
  never examined. The managed workflow Turn passes it; the lexical
  pre-launch scan stays as the model-friendly early refusal.
- acceptChildWorkflow's foreground arm parks #13708's durable
  commitAwaitAgent checkpoint before it waits, exactly as the child
  agent arm does, so a restart re-enters the wait from the journal
  instead of declining the parent's Turn unresolved_after_settle.

Merge-integration cleanups ride along: the member-chain walk narrows
by kind where the roster view needs ChildAgentRun, and the recovery
gap fill and the shared ended text name the run's kind honestly.

Witnesses: indirection forms now settle failed with the named refusal;
the park commit is asserted with the exact checkpoint evidence; the
canonical-args refusal of the existing pre-commit gate is pinned
end to end (top-level and nested subnormals, nothing committed).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): settle a registry-cancelled workflow start as cancelled, not failed (#13803)

The real stack showed a WorkflowStartCancelledError — cancelStarting or
abortAll reaching the run's controller while the Turn's own signal stays
live — settling the child as child_failed because only the abort signal
classified the outcome. The turn now reclassifies that cause to
cancelled/cancelled, the same reading the Workflow tool gives for the
identical error; deadline_exceeded keeps its name, and error keeps its
name — only the generic error outcome yields to the cause.

Witness: the harness session settles with a cancelled turn_result when
the workflow start is torn down registry-side.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk-java): normalize lone surrogates before the digest and the insert, and keep protocol messages' field paths (#13803)

Two digest-honesty findings from the real stack and the reviews:

- The canonical encoder escapes an unpaired surrogate in the admission
  digest, while no store column keeps the character; re-encoding the
  read-back input then never matches the payloadDigest, and the Turn
  retries forever (payloadDigest does not match promptContent). Input
  admission now trades unpaired surrogates for U+FFFD before the payload
  digest, the replay digest and the insert, on the API path and on the
  child-creation path, so all three describe one representation.
- Four DaemonProtocolException messages that named only the context lost
  their field path in this PR's digest work; they name the field again,
  pinned by a witness that reads all four texts.

Witnesses: the child-creation and user-text admissions normalize before
the captured digest and insert, with valid pairs and non-strings left
untouched; the helper-rejection tests pass unchanged.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(design): retire the stale closed-gate claims and record the runtime slice's settled honesty contract (#13803)

The H4c design's items 5 and 6 still named the kind gated and the Java
paths child-agent-only; the kind doc now names them closed only in its
own slice and opened with this one, in both languages. The runtime
design gains the behaviors the review rounds settled: the runner's
refuseNestedWorkflows line behind the lexical scan, the waiter arm's
durable park, the registry-side cancel settling cancelled through
WorkflowStartCancelledError, and lone-surrogate normalization at
input admission keeping the digest and the column one representation.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk-java): satisfy Checkstyle NeedBraces on the surrogate-normalization helpers (#13803)

The helpers added in 52958aade4 landed bare for-loop bodies; the
managed-agent-server Checkstyle lane (NeedBraces) refuses them on real
runs. Wrap all three loop bodies in braces - no behavior change.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): write the hosted workflow turn fixture's NUL byte as a printable escape (#13803)

The pin-tamper fixture embedded a literal NUL inside the definitionId
string, which makes the file binary to git and to review tooling. Write
it as \x00; the test asserts the same byte.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): refuse protocol-ceiling tool inputs per call instead of wedging the Turn (#13803)

prepareRequests computed the canonical digests unguarded, so a tool
input past the protocol JSON ceiling (depth/size) threw out of the
whole parent Turn and settled it hosted_turn_failed (real-stack
round 2, D1). The canonical-digest calculation now converts
ManagedToolProtocolError into that call's own model-visible validation
refusal - "Hosted tool input cannot commit as protocol evidence: ..." -
while the inline Session Store limit keeps main's rejection contract
exactly. The workflow-child witness pins the refusal text and that no
record commits under the ceiling.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): give the managed workflow child's in-process subagents no tools at all (#13803)

The real-stack round-2 review found the child script's agent()
subagents dispatching tools against this Harness's own cwd and host,
never the Session's Workspace. In v1 the child declares no tools at
dispatch (WorkflowRunner.refuseSubagentTools threaded into
createProductionDispatch): the fast path hands AgentHeadless an empty
declaration list, while the override path denies every requested tool
by name through the existing narrowAgentTools refusal. Each arm sits
under its own witness, verified red by disabling its guard. Routing
subagent tools through the Session's Hosted tool path is the named
follow-up; the runtime design records the v1 posture in both
languages.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk-java): encode non-contract request values instead of refusing them (#13803)

The canonical encoder that replaced fastjson2 for every daemon request
body threw on any value outside Map/List/String/Boolean/Number. The
channel submit_input body carries its policy as the Jackson tree read
from the store column, so every inbound channel event was refused with
400 and all six FG7 channel fault gates failed (0 physical sends).

Such values now render as fastjson2 rendered them before, are read back
strictly into contract values, and are then canonicalized, so the wire
bytes stay canonical whatever the caller handed in.

* fix(core): give a tool-refusing workflow host's override-path agents no tools and no host-bound options (#13803)

The managed workflow child runs with refuseSubagentTools, but only the
fast path declared an empty tool list: any dispatch-affecting option
(model, schema, effort) took the override path, where an absent
allowlist is the subagent manager's '*' floor, so the subagent got the
Harness host's whole registry. The override path now declares [] (or
only structured_output for a schema dispatch), and agentType, isolation
and workingDir, which resolve definitions, start MCP servers or provision
worktrees on this host, are refused before anything resolves.

* fix(cli): state and admit the managed workflow's text-only agents up front (#13803)

The declaration advertised agent() without saying its agents carry no
tools here, so a model wrote the ordinary tool-bearing shape and the run
failed inside the child after a launch slot was spent. The declaration
now states the posture, and admission refuses a script whose agent()
literally passes tools, agentType, isolation or workingDir before any
record commits; options built elsewhere still meet the runtime refusal.

* fix(cli): refuse workflow args where the digest would, and over-limit encodings per call (#13803)

- The args walk now starts at the level the protocol digest counts
  args at, so it refuses exactly where that digest would throw instead
  of passing a band the digest then refuses (R3-6).
- The subnormal number arm is gone: the Java digest substrate now
  renders every finite double the way JSON.stringify does (R1-3, R4-6).
- An arm's own refusal is no longer overwritten by the generic
  protocol-evidence text.
- A workflow launch (or any already-refused call) whose input passes
  the inline Session Store limit once JSON-encoded twice answers a
  per-call byte_limit refusal instead of failing the Turn (R2-12); such
  a batch is never captured for G3 recovery.

* fix(cli): encode a child launch only on its fresh path, never before its replay (#13803)

The admitLaunch extraction evaluated the envelope encode as an eager
argument, so a re-driven agent launch whose member preamble had grown
past the 32 KiB bound since its commit threw byte_limit before the replay
branch could name the committed record, recovery-blocking the Turn. The
bytes are now a thunk read only where a fresh launch publishes them or
the default matcher compares them.

* fix(cli): run the workflow lane on the Session's pinned model (#13803)

- runHostedWorkflowTurn now takes the Session's AgentDefinition and
  applies its model pin through the same helper the text Turn uses
  (exactly one configured non-runtime match, cached credentials, the
  registry base URL); a pin that does not resolve fails the Turn instead
  of running every agent() on the default model (R3-4).
- The assistant commit is no longer duplicated across the two lanes
  (R4-5).
- The truncation marker no longer points at a run snapshot that lives on
  the Harness host, where this Session's tools never read (R1-32).

* fix(core): refuse a non-finite number in workflow launch args instead of writing null (#13803)

* test(core): state which narrowing step is the workflow gate test's control (#13803)

* test(cli): abort the half-batch deterministically at the first admission (#13803)

The test observed the first record from a 10 ms polling timer, so the
abort sometimes landed after the second launch was minted (about one
run in four). It now aborts as the first admitWorkflow commit returns;
the batch admits launches one after another, so the second meets the
cancel guard every time (20/20), and removing the guard turns it red.

* refactor(core): hand the pre-launch gate's compile and shape to its callers (#13803)

workflowPreLaunchRefusal compiled the script and scanned its shape, then
threw both away, so the runner compiled the same bytes again and the
hosted admission scanned them again. workflowPreLaunchGate returns the
meta and shape of a script that passes; the runner reads its meta from
it and the admission its shape (R2-13).

* fix(cli): answer a committed workflow launch on recovery and name every child answer by its record's kind (#13803)

- The settle and resume routes looked a committed launch up only for an
  agent call, so a committed workflow launch was answered by neither
  (R3-9); both now answer it by what committed, like an agent launch.
- The kind label is derived from the record in one helper, so the ended
  sentence, the background started receipt and the notification can no
  longer name a workflow child 'Child agent'; the recovery fill's
  background receipt now names the workflow kind too (R3-7, R3-8, R4-7).
- A message addressed to a workflow child's task is refused as a
  workflow task instead of as an unknown one (R2-10).

* test: make the workflow tests assert what their names claim (#13803)

- The broker-warm test runs a tool-profiled Session, so only the
  workflow-launch half of the guard keeps the tool turn away.
- The Turn-owned mount hold takes the background launch it advises.
- The over-size envelope case reaches the envelope's own bound with
  three-byte args, and the encoder's boundary gains a UTF-8 case.
- The funnel test reads the notification the funnel wrote for its kind.
- A launch re-driven at the full concurrency cap names its record (R2-8,
  R4-8).

* fix(sdk-java): derive canonical number digits JDK-independently (#13803)

JsonSupport.encode took its digits from Double.toString (via
BigDecimal.valueOf), whose shortest-round-trip guarantee only exists from
JDK 19; on the Java 11/17 runtimes this artifact supports, doubles past
2^53 picked up a 17th digit JSON.stringify never prints, so the prompt
digest disagreed with the daemon's. The subnormal band rendered a
BigDecimal literal's own digits (3E-324 as 3e-324, not 5e-324) and
refused the same double when it arrived as a Double.

Numbers now render as ECMAScript Number::toString of the double the
script side holds, derived from the double's exact value: the first
digit count whose floor or ceiling neighbour round-trips, the closer one,
the even one on a tie. Every finite double encodes on every JDK, and a
Node-generated oracle of 10,694 doubles (every binade boundary, random
subnormals, each notation band) pins it.

The builder now refuses a BigDecimal or BigInteger past the double range
up front, and an admission body that fails to encode never leaves the
client and releases the session's prompt slot instead of holding it
forever. The per-character escape no longer goes through String.format.

* fix(managed-agent): name a workflow launch in the projected user message (#13803)

A workflow child's first turn input is one workflow_launch block with no
text key, so materializeInput skipped it and the public message list showed
a user item with zero parts. The projection now renders the launch pin as
one input_text part, in the same shape as the daemon journal's
workflowLaunchSummaryText; the block itself keeps its closed key set.

* test(managed-agent): pin the relay's unknown-kind refusal and the creation replay digest (#13803)

refusesToCreateAnUnknownKindInsteadOfGuessingAnEnvelope verified the
5-arg createChildSession overload the relay never calls, so a guess that
took the prompt arm stayed green. Verify the 6-arg overload (also in the
workflow drive test) and assert the deferral reason names the refusal.

The lost-reply replay test compared the replay digest with the attempted
one, which holds for any derivation. Both now equal a sha256 of the
canonical creation request computed in the test itself.

* refactor(managed-agent): share the child Session kind list and retire the stale cancel premise (#13803)

The relay discovery page and the close cascade each hand-listed
task_kind IN ('child_agent', 'workflow'); both now read one constant so a
third kind joins both queries or neither.

CANCELLABLE_TASK_KINDS still said the workflow kind has no runtime. It
does now; its public cancel is a named follow-up slice, so the set stays
closed by slice boundary.

* docs(managed-agent): retire stale workflow-gate claims and record the subagent tool posture (#13803)

The H4c and I2 designs now mark the closed-gate statements the runtime
slice superseded (decision 3, decision 7, the runtime non-goal, the
validation notes and the acceptance criterion; the I2 quarantine lines),
and quote the shipped gate value with H3's shell. The H4a delivery row
names what the workflow slice still defers. The runtime design records
the migration numbers at the head (V68 highest, V69 next free), fixes
decision 6's broken link to workflow-agent-tools.ts, states that both
subagent dispatch paths declare no tools and that admission refuses an
agent() tools option, and adds the subagent-tools follow-up row.

* docs(managed-agent): name every host-bound agent() option the workflow child refuses (#13803)

* chore: type the funnel notification read and declare the oracle generator's node global (#13803)

* fix(managed-agent): hand a tool-refusing workflow agent its runtime tool list (#13803)

The override path wrote the refusing host's empty allowlist onto the
SubagentConfig, but the subagent manager reads an empty definition
allowlist as "inherit everything" and convertToRuntimeConfig turns it
into '*'. A Hosted workflow agent() carrying model or effort therefore
received the Harness's whole registry, read_file included.

Pass the list as the manager's toolConfigOverride instead: [] for a
plain dispatch, structured_output alone for a schema dispatch. The
unit tests now pin the runtime override the manager receives.

* fix(managed-agent): start Hosted workflow agents without the Harness's startup context (#13803)

Every agent() chat of a Hosted workflow child opened with the per-turn
config's startup context: this Harness's absolute cwd and a listing of
it. That is host filesystem metadata, never the Session's Workspace,
and it reached the model even when the agent declared no tools. The
sibling text turn never sends it because it replaces that prelude with
the Session's own history; the workflow agents have none, so the
per-turn config now skips the startup context.

hosted-workflow-turn.integration.test.ts runs the plain, model, effort
and schema dispatches through the real subagent manager and a real
per-turn config against a fixture model, with a Harness-only file in
the cwd: every request declares no tools (structured_output alone for a
schema dispatch) and carries neither the file's name, its content nor
the Harness path. It goes red with either fix reverted. The design doc
records both.

* fix(sdk-java): order canonical map keys as JSON.stringify enumerates them (#13803)

JsonSupport.encode wrote a map's keys in the map's iteration order, but
JSON.stringify writes array-index keys ("0".."4294967294", no leading
zero) first in ascending numeric order and only then the rest in
insertion order. The daemon re-parses a body and stringifies it that
way, so a map built or read with integer-like keys out of that order
would digest differently on the two sides. Maps with no array-index key
keep the original iteration path.

* test(sdk-java): pin signed zeros and JSON.stringify key order in the canonical encoder (#13803)

The generated oracle never reached -0: its loops clear or randomize the
sign elsewhere. canonical-doubles.mjs now ends with 0 and -0 (appended,
so the seeded lines before them are unchanged), and the golden vectors
carry 0.0 and -0.0 explicitly.

A key-order golden test pins Node's output for an object with
array-index, leading-zero, out-of-range, negative, fractional and
padded keys, flat and nested. It goes red with the encoder's own-key
ordering reverted.

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-10-13 05:05:40 +00:00
..
src feat(managed-agent): H4 workflow child runtime (#13858) 2026-10-13 05:05:40 +00:00
.editorconfig refactor structure 2026-02-04 18:38:38 +08:00
.gitignore refactor structure 2026-02-04 18:38:38 +08:00
checkstyle.xml refactor structure 2026-02-04 18:38:38 +08:00
LICENSE refactor structure 2026-02-04 18:38:38 +08:00
pom.xml chore(sdk-java): bump fastjson2 to 2.0.65 (#12465) 2026-09-22 11:45:31 +00:00
QWEN.md feat(sdk-java): Add daemon transport (#7463) 2026-07-23 12:38:34 +00:00
README.md docs(sdk): clarify empty core-tool allowlists across SDKs (#13949) 2026-10-12 13:19:59 +00:00
RELEASE.md feat(sdk-java): Add daemon transport (#7463) 2026-07-23 12:38:34 +00:00

Qwen Code Java SDK

The Qwen Code Java SDK provides a recommended daemon transport for qwen serve and retains the experimental legacy stdio API for compatibility. Both APIs ship in the same com.alibaba:qwencode-sdk artifact.

Requirements

  • Java >= 11 for 0.1.0-alpha
  • Maven >= 3.9.2 when building or publishing this SDK from source
  • A compatible qwen serve for the daemon API, or qwen-code >= 0.5.0 for the legacy stdio API

Dependencies

  • Logging API: org.slf4j:slf4j-api (choose an SLF4J provider in your application)
  • Utilities: org.apache.commons:commons-lang3
  • JSON Processing: Fastjson2 for encoding and Jackson Core for strict decoding
  • Testing: JUnit 5 (org.junit.jupiter:junit-jupiter)

Installation

Add the following dependency to your Maven pom.xml:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>qwencode-sdk</artifactId>
    <version>0.1.0-alpha</version>
</dependency>

Or if using Gradle, add to your build.gradle:

implementation 'com.alibaba:qwencode-sdk:0.1.0-alpha'

Building and Running

Build Commands

# Compile the project
mvn compile

# Run tests
mvn test

# Package the JAR
mvn package

# Install to local repository
mvn install

Real daemon E2E from source

Run the real-daemon Java integration tests from the repository root after building both the workspaces and the root CLI bundle:

npm run build
npm run bundle
npx tsx scripts/run-java-daemon-sdk-e2e.ts

npm run build alone does not refresh dist/cli.js; the E2E harness launches that bundle and fails with an explicit prerequisite error when it is missing.

Start qwen serve, then create an independent thread-scoped session. promptText returns only after a matching turn_complete; incomplete streams fail with PromptOutcomeIndeterminateException rather than returning partial text as success.

For the lifecycle guarantees assumed by 0.1.0-alpha, use the qwen-code build released from the same source revision as the SDK. The daemon must contain the idempotent per-client detach ledger from #7386, the per-epoch terminal guarantee from #7400, restart-safe event cursor epochs from #7458, and this release's acknowledged admission cancellation plus FIFO cancel-drain fence. The #7400 commit alone is not sufficient: a same-wire daemon can acknowledge cancel before agent dispatch without stopping the admitted prompt, or let an unacknowledged session-scoped cancel reach a queued successor. The bundled ACP child uses one acknowledged admission-aware cancellation handshake; a custom standards-compliant ACP child without that extension receives one standard session/cancel notification. Feature negotiation cannot distinguish older same-wire daemon builds, so the SDK fails closed rather than reporting partial output as success.

The bundled cancellation handshake deliberately waits for the targeted prompt call to settle before the daemon dispatches its queued successor. It has no timeout that merely acknowledges cancellation: doing so could let a late session-scoped cancel reach the next prompt. If a provider, tool, or custom integration ignores its AbortSignal indefinitely, the cancel mutation can therefore remain outcome-unknown and that session must not be reused. Treat a formal prompt terminal received within the caller's observation boundary as authoritative; otherwise close or destroy the session after observation fails. Recovering a wedged shared ACP child without disturbing its sibling sessions requires stronger runtime isolation and is outside this alpha contract.

import com.alibaba.qwen.code.daemon.DaemonClient;
import com.alibaba.qwen.code.daemon.DaemonSessionClient;
import com.alibaba.qwen.code.daemon.PromptTextResult;
import java.net.URI;

try (DaemonClient daemon = DaemonClient.builder()
        .baseUri(URI.create("http://127.0.0.1:4170"))
        .build();
     DaemonSessionClient session = daemon.createSession()) {
    PromptTextResult result = session.promptText("Explain this repository");
    System.out.println(result.getText());
}

Callers that need to allocate the session identity before creation can pass an RFC UUID v1-v5. The SDK checks session_id_override before the mutation and reports a different returned ID as SessionCreationOutcomeUnknownException:

CreateSessionRequest request = CreateSessionRequest.builder()
        .sessionId("550E8400-E29B-41D4-A716-446655440000")
        .build();

try (DaemonSessionClient session = daemon.createSession(request)) {
    System.out.println(session.getSession().getSessionId());
}

The daemon normalizes the ID to lowercase and creates a new thread session. This is not an idempotent attach; after an ambiguous create outcome, recover with the known ID rather than retrying creation.

If qwen serve requires authentication, add .bearerToken(System.getenv("QWEN_SERVER_TOKEN")) to the DaemonClient builder. The SDK sends the bearer on REST and SSE requests and never puts it in the URL.

Use startPrompt with a PromptObserver when you need ordered text, thought, tool, usage, permission, and raw event callbacks. Its acceptanceFuture() and completionFuture() views separately expose daemon admission and the reliable turn terminal. respondToPermission() returns false when the request was already resolved or no longer pending. Cancelling the future views does not cancel the daemon prompt; use cancelActivePrompt() for the session-level daemon cancel operation and still wait for the matching terminal. A cooperative cancellation completes with turn_complete and stopReason=cancelled; promptText() returns its PromptTextResult, so callers that distinguish cancellation must inspect result.getTerminal().getStopReason(). If the agent or provider fails while cancelling, the daemon can instead publish turn_error, which makes promptText() throw PromptTurnException.

When cancellation, deadline, teardown, or agent settlement race, the daemon's exactly-once latch publishes the first formal terminal and suppresses later candidates. Always branch on the received terminal itself; the last control mutation sent by the client does not determine the terminal kind or error code.

The SSE transport sends Accept-Encoding: identity and Last-Event-ID, pairs the cursor with X-Qwen-Event-Epoch when the daemon supplies an epoch, validates framing and event IDs, deduplicates replay, and reconnects only the SSE GET. It learns an epoch from the validated prompt admission or SSE response headers and fails closed if the epoch changes during prompt observation. Older daemons that omit both surfaces remain compatible but retain their numeric-only stale-cursor detection. Prompt and other mutation requests are never retried automatically. HTTP 408 and 5xx responses to prompt admission, session creation, permission, cancel, heartbeat, detach, or delete are reported as outcome-unknown because they do not prove that the daemon rejected the mutation. Finite response bodies and SSE observation have independent deadlines.

Creation-time model selection is intentionally not exposed by the Java daemon SDK API in this alpha. The daemon reports a rejected modelServiceId only as an SSE event emitted before the create response, while this SDK opens its stream from the later prompt-admission watermark. Until the daemon returns a definitive create result or the SDK owns a separate session-event subscription from Last-Event-ID: 0, use the daemon's configured default model.

PromptRequest.Builder.deadline(Duration) requests a daemon-enforced prompt deadline and is accepted only when the daemon advertises prompt_absolute_deadline; otherwise the SDK fails before sending the prompt. The value must be between 1 and 2,147,483,647 milliseconds, matching the daemon's Node timer range. This is separate from observationTimeout(Duration), which only bounds local SSE observation and never sends a cancel mutation.

Before creating a session, the SDK requires the daemon to advertise the REST transport and session_scope_override; this prevents an older daemon from silently ignoring the requested thread scope and attaching the client to a shared session. When a caller supplies a session ID, the SDK additionally requires session_id_override before sending the mutation. When client_heartbeat is advertised, an open session sends a fresh heartbeat every minute so the daemon does not reap an otherwise idle client. Set heartbeatInterval(Duration.ZERO) on the DaemonClient builder to disable this behavior, or choose a different positive interval. A heartbeat is never retried; the next scheduled heartbeat is a separate keepalive. Prompt observation is bounded to 32 concurrent prompts per client by default and can be adjusted with maximumConcurrentPrompts. Admission and terminal future callbacks run away from transport workers; callbacks that remain blocked consume bounded publication capacity. SSE stream cleanup is also bounded, and a close that remains blocked retains its cleanup reservation. Either condition can cause a later startPrompt to fail with DaemonClientCapacityException rather than dropping a timeout close or growing threads and queued work without limit.

An indeterminate completion is an outcome boundary, not a session-reuse boundary. After PromptAdmissionUnknownException or PromptOutcomeIndeterminateException, that DaemonSessionClient permanently rejects further prompts even if local stream cleanup later succeeds; close or destroy the session instead. An observation timeout is published without waiting forever for a blocked stream close, while cleanup continues asynchronously and retains bounded client capacity until it finishes.

Hosted Harness private API

HostedHarnessClient is the Java control-plane transport for qwen serve --profile hosted-harness. Construction performs capability negotiation, verifies the deployment capability digest, and pins the process bootId. Every Session request then carries protocol and generation fencing automatically. This API keeps model-loop events raw; product services remain responsible for public Session, Turn, Event, tenant, and workspace authorization state.

Map<String, Object> text = Map.of("type", "text", "text", "hello");
String payloadDigest = SubmitHarnessTurn.computePayloadDigest(List.of(text));

try (HostedHarnessClient harness = HostedHarnessClient.builder()
        .baseUri(URI.create("http://127.0.0.1:4170"))
        .bearerToken(System.getenv("QWEN_SERVER_TOKEN"))
        .capabilityDigest(System.getenv(
                "QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST"))
        .build()) {
    HarnessSessionRef session = harness.createSession(
            CreateHarnessSession.builder()
                    .harnessSessionId(UUID.randomUUID().toString())
                    .build());
    PromptReceipt receipt = harness.submitTurn(
            SubmitHarnessTurn.builder()
                    .session(session)
                    .promptId(UUID.randomUUID().toString())
                    .addContent(text)
                    .payloadDigest(payloadDigest)
                    .build());
    try (HarnessEventStream events = harness.streamEvents(
            StreamHarnessEvents.builder()
                    .session(session)
                    .lastEventId(receipt.getLastEventId())
                    .eventEpoch(receipt.getEventEpoch())
                    .build())) {
        for (DaemonEvent event = events.next(); event != null;
                event = events.next()) {
            // Persist/project the raw Harness event in the Java product service.
        }
    }
    harness.detachSession(session);
}

The client permits only one active prompt identity per Session while allowing different Sessions to run concurrently. A retry after PromptAdmissionUnknownException must reuse the same promptId, payload digest, prompt content, and deadline. SSE boot, epoch, or sequence changes fail closed for transcript/status reconciliation. close() releases local HTTP, heartbeat, timer, and stream resources only; it never destroys remote Sessions implicitly.

Legacy stdio API

The existing com.alibaba.qwen.code.cli API remains available:

public static void runSimpleExample() {
    List<String> result = QwenCodeCli.simpleQuery("hello world");
    result.forEach(logger::info);
}

For more advanced usage with custom transport options:

public static void runTransportOptionsExample() {
    TransportOptions options = new TransportOptions()
            .setModel("qwen3-coder-flash")
            .setPermissionMode(PermissionMode.AUTO_EDIT)
            .setCwd("./")
            .setEnv(new HashMap<String, String>() {{put("CUSTOM_VAR", "value");}})
            .setIncludePartialMessages(true)
            .setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS))
            .setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS))
            .setAllowedTools(Arrays.asList("read_file", "write_file", "glob"));

    List<String> result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options);
    result.forEach(logger::info);
}

For streaming content handling with custom content consumers:

public static void runStreamingExample() {
    QwenCodeCli.simpleQuery("who are you, what are your capabilities?",
            new TransportOptions().setMessageTimeout(new Timeout(10L, TimeUnit.SECONDS)), new AssistantContentSimpleConsumers() {

                @Override
                public void onText(Session session, TextAssistantContent textAssistantContent) {
                    logger.info("Text content received: {}", textAssistantContent.getText());
                }

                @Override
                public void onThinking(Session session, ThinkingAssistantContent thinkingAssistantContent) {
                    logger.info("Thinking content received: {}", thinkingAssistantContent.getThinking());
                }

                @Override
                public void onToolUse(Session session, ToolUseAssistantContent toolUseContent) {
                    logger.info("Tool use content received: {} with arguments: {}",
                            toolUseContent, toolUseContent.getInput());
                }

                @Override
                public void onToolResult(Session session, ToolResultAssistantContent toolResultContent) {
                    logger.info("Tool result content received: {}", toolResultContent.getContent());
                }

                @Override
                public void onOtherContent(Session session, AssistantContent<?> other) {
                    logger.info("Other content received: {}", other);
                }

                @Override
                public void onUsage(Session session, AssistantUsage assistantUsage) {
                    logger.info("Usage information received: Input tokens: {}, Output tokens: {}",
                            assistantUsage.getUsage().getInputTokens(), assistantUsage.getUsage().getOutputTokens());
                }
            }.setDefaultPermissionOperation(Operation.allow));
    logger.info("Streaming example completed.");
}

other examples see src/test/java/com/alibaba/qwen/code/cli/example

Java 11 migration and alpha limits

0.1.0-alpha raises the minimum Java version for the whole artifact from 8 to 11. Java 8 applications must remain on 0.0.3-alpha. Logback is no longer a runtime dependency; add the SLF4J provider your application uses.

This alpha deliberately fails closed when it cannot prove a prompt terminal. It detects a daemon event-epoch change during an observed prompt but does not automatically recover from it. It does not guarantee exactly-once execution across daemon restarts, automatic epoch recovery, snapshot/resync, persisted cursors, or true prompt-ID-targeted cancellation. prompt_cancelled and queue events are advisory; only matching turn_complete and turn_error are terminal.

If session creation has an ambiguous transport outcome, the daemon may retain a session whose ID never reached the caller. The SDK does not retry creation and cannot detach that unknown session; daemon-side lifecycle reaping is the recovery boundary.

Architecture

The artifact contains two isolated implementations:

  • Daemon API: DaemonClient and DaemonSessionClient use REST mutations plus resumable SSE and own bounded HTTP, prompt, maintenance, and timer resources.
  • Legacy stdio API: QwenCodeCli, Session, and ProcessTransport manage a child CLI process using the existing CLI protocol DTOs and utilities.

The daemon implementation does not reuse the legacy process transport, session model, DTOs, or global executor.

Legacy stdio features

Permission Modes

The SDK supports different permission modes for controlling tool execution:

  • default: Write tools are denied unless approved via canUseTool callback or in allowedTools. Read-only tools execute without confirmation.
  • plan: Blocks all write tools, instructing AI to present a plan first.
  • auto-edit: Auto-approve edit tools (edit, write_file, notebook_edit) while other tools require confirmation.
  • auto: An LLM classifier approves tool calls.
  • yolo: All tools execute automatically without confirmation.

Session Event Consumers and Assistant Content Consumers

The SDK provides two key interfaces for handling events and content from the CLI:

SessionEventConsumers Interface

The SessionEventConsumers interface provides callbacks for different types of messages during a session:

  • onSystemMessage: Handles system messages from the CLI (receives Session and SDKSystemMessage)
  • onResultMessage: Handles result messages from the CLI (receives Session and SDKResultMessage)
  • onAssistantMessage: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage)
  • onPartialAssistantMessage: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage)
  • onUserMessage: Handles user messages (receives Session and SDKUserMessage)
  • onOtherMessage: Handles other types of messages (receives Session and String message)
  • onControlResponse: Handles control responses (receives Session and CLIControlResponse)
  • onControlRequest: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse)
  • onPermissionRequest: Handles permission requests (receives Session and CLIControlRequest, returns Behavior)

AssistantContentConsumers Interface

The AssistantContentConsumers interface handles different types of content within assistant messages:

  • onText: Handles text content (receives Session and TextAssistantContent)
  • onThinking: Handles thinking content (receives Session and ThinkingAssistantContent)
  • onToolUse: Handles tool use content (receives Session and ToolUseAssistantContent)
  • onToolResult: Handles tool result content (receives Session and ToolResultAssistantContent)
  • onOtherContent: Handles other content types (receives Session and AssistantContent)
  • onUsage: Handles usage information (receives Session and AssistantUsage)
  • onPermissionRequest: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior)
  • onOtherControlRequest: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload)

Relationship Between the Interfaces

Important Note on Event Hierarchy:

  • SessionEventConsumers is the high-level event processor that handles different message types (system, assistant, user, etc.)
  • AssistantContentConsumers is the low-level content processor that handles different types of content within assistant messages (text, tools, thinking, etc.)

Processor Relationship:

  • SessionEventConsumers → AssistantContentConsumers (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages)

Event Derivation Relationships:

  • onAssistantMessage → onText, onThinking, onToolUse, onToolResult, onOtherContent, onUsage
  • onPartialAssistantMessage → onText, onThinking, onToolUse, onToolResult, onOtherContent
  • onControlRequest → onPermissionRequest, onOtherControlRequest

Event Timeout Relationships:

Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event:

  • onSystemMessage ↔ onSystemMessageTimeout
  • onResultMessage ↔ onResultMessageTimeout
  • onAssistantMessage ↔ onAssistantMessageTimeout
  • onPartialAssistantMessage ↔ onPartialAssistantMessageTimeout
  • onUserMessage ↔ onUserMessageTimeout
  • onOtherMessage ↔ onOtherMessageTimeout
  • onControlResponse ↔ onControlResponseTimeout
  • onControlRequest ↔ onControlRequestTimeout

For AssistantContentConsumers timeout methods:

  • onText ↔ onTextTimeout
  • onThinking ↔ onThinkingTimeout
  • onToolUse ↔ onToolUseTimeout
  • onToolResult ↔ onToolResultTimeout
  • onOtherContent ↔ onOtherContentTimeout
  • onPermissionRequest ↔ onPermissionRequestTimeout
  • onOtherControlRequest ↔ onOtherControlRequestTimeout

Default Timeout Values:

  • SessionEventSimpleConsumers default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS)
  • AssistantContentSimpleConsumers default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS)

Timeout Hierarchy Requirements:

For proper operation, the following timeout relationships should be maintained:

  • onAssistantMessageTimeout return value should be greater than onTextTimeout, onThinkingTimeout, onToolUseTimeout, onToolResultTimeout, and onOtherContentTimeout return values
  • onControlRequestTimeout return value should be greater than onPermissionRequestTimeout and onOtherControlRequestTimeout return values

Transport Options

The TransportOptions class allows configuration of how the SDK communicates with the Qwen Code CLI:

  • pathToQwenExecutable: Path to the Qwen Code CLI executable
  • cwd: Working directory for the CLI process
  • model: AI model to use for the session
  • permissionMode: Permission mode that controls tool execution
  • env: Environment variables to pass to the CLI process
  • maxSessionTurns: Limits the number of conversation turns in a session
  • coreTools: A non-empty allowlist of core tools. An omitted or empty list is treated as unspecified; an empty list does not disable tools. MCP and other non-core tools have separate registration and permission rules. Use excludeTools to exclude named tools explicitly; allowedTools controls approval rather than registration.
  • excludeTools: List of tools to exclude from being available to the AI
  • allowedTools: List of tools that are pre-approved for use without additional confirmation
  • authType: Authentication type to use for the session
  • includePartialMessages: Enables receiving partial messages during streaming responses
  • turnTimeout: Timeout for a complete turn of conversation
  • messageTimeout: Timeout for individual messages within a turn
  • resumeSessionId: ID of a previous session to resume
  • otherOptions: Additional command-line options to pass to the CLI

Session Control Features

  • Session creation: Use QwenCodeCli.newSession() to create a new session with custom options
  • Session management: The Session class provides methods to send prompts, handle responses, and manage session state
  • Session cleanup: Always close sessions using session.close() to properly terminate the CLI process
  • Session resumption: Use setResumeSessionId() in TransportOptions to resume a previous session
  • Session interruption: Use session.interrupt() to interrupt a currently running prompt
  • Dynamic model switching: Use session.setModel() to change the model during a session
  • Dynamic permission mode switching: Use session.setPermissionMode() to change the permission mode during a session

Thread Pool Configuration

The SDK uses a thread pool for managing concurrent operations with the following default configuration:

  • Core Pool Size: 30 threads
  • Maximum Pool Size: 100 threads
  • Keep-Alive Time: 60 seconds
  • Queue Capacity: 300 tasks (using LinkedBlockingQueue)
  • Thread Naming: "qwen_code_cli-pool-{number}"
  • Daemon Threads: true
  • Rejected Execution Handler: CallerRunsPolicy

Tasks still running or queued in the default pool are abandoned when the JVM exits. Callers that require completion must wait for those tasks explicitly.

Error Handling

The SDK provides specific exception types for different error scenarios:

  • SessionControlException: Thrown when there's an issue with session control, including attempting to use a closed or unavailable session. Session construction and start() can throw it directly; QwenCodeCli.newSession() wraps lower-level creation and initialization failures in a RuntimeException.
  • SessionSendPromptException: Thrown when there's an issue sending a prompt or receiving a response

FAQ / Troubleshooting

Q: What Java versions are supported?

A: 0.1.0-alpha requires Java 11 or higher. Java 8 users must remain on 0.0.3-alpha.

Q: How do I handle long-running requests?

A: The SDK includes timeout utilities. You can configure timeouts using the Timeout class in TransportOptions.

Q: Why are some tools not executing?

A: This is likely due to permission modes. Check your permission mode settings and consider using allowedTools to pre-approve certain tools.

Q: How do I resume a previous session?

A: Use the setResumeSessionId() method in TransportOptions to resume a previous session.

Q: Can I customize the environment for the CLI process?

A: Yes, use the setEnv() method in TransportOptions to pass environment variables to the CLI process.

License

Apache-2.0 - see LICENSE for details.