qwen-code/packages/sdk-java/qwencode
Shaojin Wen a7e26adde7
feat(managed-agent): H4d-b session message runtime (#13822)
* feat(managed-agent): H4d-b session message runtime

Produce the records the H4d-a contract pinned. The Hosted Shell lanes
declare a managed send_message: a parent messages the child tasks it
launched by task id, a child messages its parent. A running child gets a
message, a completed child gets a continuation that carries its chain's
history, and any other ending gets a named refusal. The routing decision
shares the child funnel's writes chain with settlement, so a message
either opens before the run settles and holds that settlement, or finds
the run ended.

A new message relay in managed-agent-server fixes each message's target
at the handover, commits the receipt into the target together with the
input and wake that carry it, and advances the sender as the target
accepts and consumes it, with its own V61 ledger for leases, backoff and
the orphaned/unknown classifications. The target reads a message as a
wake turn and consumes the receipt when that turn completes.

The child result relay now settles a child only once no message on its
edge owes a handover and its journal holds no pending input, and takes
the result from the child's newest settled turn, read from the child's
own journal when a message's wake turn ran last. The parent refuses a
result commit with child_messages_pending while a message to the run is
undelivered. A continuation's first input is composed from the parent's
committed instructions and results, bounded below the Hosted prompt
limit.

session_message joins the enabled domains, the continuation gate opens,
the Hosted recovery allowlist admits the domain, and send_message joins
the input-preview set in both languages. No public contract change.

Refs #12380, #12827, #13744 (slice H4d-b)

* fix(managed-agent): close the H4d-b round-1 audit findings

Five independent audits of the session message runtime found these.
Each one is closed here.

A give-up now ends the entry on its sender before it classifies the
ledger row unknown: cancelled if the entry was never handed over,
accepted if its receipt exists, unknown otherwise. Undelivered now means
planned or accepting in both languages, so a message the relay gave up
on no longer holds the child's settlement forever.

The child result relay reads the child's journal first and its edge
after. Only delivered messages waiting for their turn hold the
settlement, and only for 30 minutes without activity. The result is the
newest settled API or message turn. The settlement names the
messageCount the relay read, and the parent refuses it when a newer
message exists, which closes the window between the reads and the
commit. A committed result is accepted without being recomputed.

The message relay decides from the entry as committed now. It pages
waiting entries from its ledger index, so an accepted entry whose
advance was lost is not stranded. It cancels messages to a child being
stopped. A target store fault is a 503 retry, never a rejection. A
closed sender's entry is classified delivered when its receipt exists.
Waiting entries are reconciled through a new consume operation, which
also reloads a target that a replaced Harness dropped.

Senders are bounded to 8 messages in flight and 64 in all per child run
and direction. A closing parent sends no messages. Refusals from the
store become tool errors. A receipt for an unknown run is refused before
anything publishes, and the route bounds the content it accepts.

Continuation history is now XML-escaped. V61 gains a per-Session index.
The migration IT lists V61. The foreground-agent sibling check no longer
exempts send_message, to match #13769's recovery.

Refs #12380, #12827, #13744 (slice H4d-b)

* fix(managed-agent): gate message receipts like other new-work relays

- Route receive and consume through the Workspace admission the
  automation relay runs, and re-attach a Session a prior control-plane
  process attached through the takeover load for every message verb.
- Refuse an accepted step whose input id is not the one the message id
  derives, pinning the Java relay's derivation to the Hosted one.
- Keep the attempt count across a forward ledger step, matching the
  child result relay.
- Record that a failed message wake turn fails the child on purpose.

* fix(managed-agent): leave the message ledger to instances with a Harness

A Session Store replica runs without a Harness, and its message relay
claimed entries it could only fail, counting attempts against them and
pushing their real worker behind the backoff. Skip the scan there, as
the turn coordinator does.

* fix(managed-agent): close the H4d-b round-2 audit findings

- Cross-check the edge against the journal read: a parent message the
  child received between the two reads holds the settlement instead of
  being closed away unread.
- Count any journal event as activity for the message-turn wait, so a
  long running message turn is never cut off at 30 minutes.
- Stop counting a closed child's orphaned outbox as owed, and hold the
  close of an active child until its own messages left.
- Let a committed result or failure stand when a newer turn would
  re-settle the run the other way.
- Read the child journal only when the child holds session messages.
- Decide a give-up from the entry as committed after the failed step.
- Count only messages not yet handed over against the in-flight cap.
- Fail a send_message call on the store's own faults instead of
  answering a refusal for a message that may still commit.
- Join journal text runs a thought separates with a newline, as the API
  turn result does.

* fix(managed-agent): bound the hold on a child's own messages by the give-up

A child that can take no further revision (a declined recovery) never
lets the message relay's give-up land its sender step, so its outbox
entries stayed planned and held the child's settlement and close
forever. An entry whose ledger row is classified or past the attempt
bound no longer holds either.

* fix(managed-agent): fail a child whose message turn never comes

Past the 30-minute wait without a journal event, a child still holding
an unread message settled from its earlier turn's result, reporting the
message as acted on; a turn queued behind a busy shared mount reaches
that bound without any fault. Settle it as child_failed instead.

* fix(managed-agent): hold a child's message for its task and refuse it once a close began

A run attaches as soon as its Runtime binding exists, before the
coordinator submits the child's task, so a parent's message could be
received first: its wake turn ran without the task and the task then
met the busy Session. The relay now hands a message to a child only once
the Harness admitted the child's task (its first Turn carries the
admission epoch), and cancels it when that task ended without an
admission, so the child's settlement is never held by it.

The message route now refuses every verb with hosted_session_closing
once the Session's close began, like the channel and automation routes,
and the close waits for an operation already past that check before it
cancels the pending inputs, so no receipt lands after that pass.

* fix(managed-agent): stop a stopped run's message turns and answer interrupted send_message calls

Main's H4f stop arm decides from the child's API Turn, but a message's
wake turn never becomes one, so a stopped run could settle cancelled
while a message turn kept running in its child, and a stop met a child
whose task had completed but whose messages were still owed as a natural
completion. For a child holding session messages the stop arm now reads
the child's journal: while a message input waits or runs it sends the
child's message route a new `stop`, which keeps the wake pump from
starting any message input, aborts a message turn in flight and, once no
turn runs, settles the waiting ones cancelled with stop_requested. The
natural outcome is the newest settled turn, API or message; a stop the
child cannot take yet spends no attempt, and a message input no turn took
within the 30-minute bound stops holding the stop. A message handed over
before the stop but not yet received is rejected instead of waking the
child.

A send_message writes only to the journal before its answer, like a team
call. Every interrupted-turn route from main's #13829, and the parked
round's gap fill that runs before them, now answers it by what it
committed: an outbox entry as queued, a continuation as continued, and
one that committed nothing as never run where the turn resumes, so the
model never sends a committed message twice.

The relay's migration moves from V61 to V64: main has V62, and other
open pull requests claim V61 and V63.

* fix(managed-agent): carry a message stop into the load that attaches its child

A stopped run's message stop went through the takeover load every message
verb uses when the control plane holds no attachment for the child. That
load drives recovery and kicks the child's wake pump before the stop
itself arrives, so a queued message turn could start in that window, and
the later stop could only abort work already begun.

The stop now has its own attachment path: a child this process does not
hold is loaded passively with `stopMessages`, which marks its message
inputs stopped before the load kicks the pump, under the cancellation
authorization a stop needs rather than the grant new work needs. A held
child takes the stop directly.

* fix(managed-agent): authorize a message stop by its committed request and keep stopped children unloaded

The message stop's own load took the cancellation authorization, whose
query needs a CANCELLING Turn. Message work never leaves one, and the
stop arm skips the Turn cancel once the task ended, so a child the
control plane no longer held could not be stopped at all. The load is
now authorized like a persisted cancellation, by the committed stop the
child result relay read: only the Session's own binding is checked, so
neither the missing Turn nor a grant changed since refuses it.

Other loads could still attach a stopped run's child without the stop.
The stop now goes out before the arm's Turn cancel, whose own load
carries none, and the message relay no longer attaches such a child for
a consume reconciliation or for the child's own outbox steps; those wait
for its close. A message turn that crashed in an earlier process is no
longer left owed: the pump skips a stopped Session's message inputs, so
the stop runs its aftermath. The Hosted load now marks a resident
Session stopped only after the load passes its identity and admission
checks, so a refused load changes nothing.

The 30-minute message-turn bound counted a resident Session's lease
renewals as activity, so it never ended a blocked child's wait; renewals
no longer count.

* fix(managed-agent): hold a stopped child's message steps only while its stop still settles message work

The message relay held every outbox step of a stopped run's child until
the child closed. When the stop met a natural end (the task completed or
failed on its own, no message work owed), the child result relay settles
through its ordinary walk, which waits for the child's own outbox to be
handed over before it settles or closes, so the two relays waited for
each other with no attempt spent and no bound to break it. The hold now
lasts only while the child still owes message work the stop is settling;
after that a load starts nothing, and the steps go on.

A resident Session's stop flag was set before the passive load checked
its tool profile, so a load refused for a profile conflict still stopped
the Session's queued messages. Each load branch now sets it only after
its own refusals.

* fix(managed-agent): bind the claimant in the relay lease CAS and refuse a closing child's message to its parent

Both relay ledgers guarded the lease takeover with the owner their read
saw rather than the claimant. Two workers that both read an expired
lease could therefore both win it: the first renewed it as its own, and
the second still matched the stale owner. The guard now binds the
claimant, so the second meets the first one's fresh lease.

A child's send_message to its parent took no closing flag, so a call in
flight when the Session's close began still opened an outbox entry the
closing sender could only orphan, while the model was told it was
queued. It now refuses like the parent-to-child arm, after a committed
message's replay.

* test(managed-agent): expect send_message among a Shell-lane Session's tools in the Hosted IT driver

The Hosted workspace tool-turn integration driver pins the exact tool set
a Shell-lane root Session advertises. Since this change that set carries
the parent form of send_message beside the Agent tool, so the MySQL
process gate failed on its first model call.
2026-10-10 16:31:38 +00:00
..
src feat(managed-agent): H4d-b session message runtime (#13822) 2026-10-10 16:31:38 +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 feat(sdk-java): Add the Hosted Harness private client (#12654) 2026-09-24 16:47:15 +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: List of core tools that should be available to the AI
  • 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.