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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

## [Unreleased]

### Added

- Add Forward Session budget builders/getters with decimal-string limits and preserved omission/null clearing, Session model overrides and typed approval evaluation details.
- Add `startupScript()` and builder setters to Forward/Managed Environment configurations. Scripts run on sandbox start/resume; self-hosted execution is controlled by the external worker.

### Fixed

- Read event-level `evaluatedPermission()` as a string in Forward and Managed.
- Stop Forward/Managed Thread resumable streams after `session.thread_status_terminated` in synchronous and asynchronous clients. Idle events keep the subscription open, and Session streams continue after a child Thread ends.

### Breaking changes

- `SessionEvent.evaluatedPermission()` returns `String` instead of `Map<String, JsonValue>` in both layers.

### Migration

- Compare `event.evaluatedPermission()` to `"allow"`, `"ask"` or `"deny"`; optional Forward automatic decision details are under `event.evaluation().evaluatedPermission()`.
- Set `budget(SessionBudget.builder().type("limit").maxCreditCost("100.00").build())` to limit Credits. Omit the setter to keep the default/current limit; `budget(null)` clears it.
- Use `SessionConfig.builder().model(ModelConfig.of("ultimate"))` to override the model. Omit on create to inherit; `.model(null)` on update restores inheritance. `field(...)` distinguishes absent and null responses.

### Changed

- Remove resource-specific sections and inventories from the README to keep it focused on SDK setup and shared client behavior.
Expand Down
4 changes: 4 additions & 0 deletions docs/environments-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,7 @@ Treat signed URLs as credentials; do not log them. This helper does not fetch co
[EnvironmentFileExample](../examples/src/main/java/com/qoder/cloudagents/examples/EnvironmentFileExample.java) creates an isolated Environment and text File, compares downloaded bytes in bounded chunks, then deletes only its own resources. It retains cleanup errors without hiding the original failure. It does not start Sessions or execute a setup script.

See [example commands](../examples/README.md) and [opt-in live testing](live-testing.md). Default tests use mock services and the installed SDK JAR.

## Startup scripts

Both Forward and Managed `EnvironmentConfig` expose `.startupScript("...")` and `startupScript()`. Cloud sandboxes run this shell script after setup on the first start and again on each resume. Put persistent installation work in `setupScript`, and restart transient services in `startupScript`. The service limits scripts to 64 KB and five minutes; startup failures surface as `environment_startup_failed_error`. Self-hosted environments store and return the script, while the external worker decides whether to execute it. The SDK transports the configuration and does not execute shell commands locally.
2 changes: 2 additions & 0 deletions docs/forward-api-coverage.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Forward API 覆盖基线

开发中契约复核(2026-11-03):补齐 Session budget(省略/null/对象)、config.model、审批 evaluation 和 Environment startup_script;修正顶层 evaluatedPermission 字符串与 Thread 终止后的续订行为。本轮没有新增 HTTP 操作;验证见 `DocumentationAlignmentTest`,真实环境结果单独记录。

契约核对:2026-10-28。**0.2.0** 覆盖 **146 个 HTTP 操作及 Realtime WebSocket**:首版 123 项、Webhook 8 项、Forward Search / Dream / Pairing / Decision 15 项。同一版本还提供 [107 个 Managed 操作](managed-api-coverage.md)。离线验证与真实环境验收分别记录,不能把操作覆盖数当作线上通过率。

HTTP BaseURL 为 CN `https://api.qoder.com.cn/api/v1/forward` 或 Global `https://api.qoder.com/api/v1/forward`;表内路径均相对于 BaseURL。
Expand Down
2 changes: 2 additions & 0 deletions docs/managed-api-coverage.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Managed API 覆盖基线

开发中契约复核(2026-11-03):修正顶层 evaluatedPermission 字符串和 Thread 终止后的续订行为,补齐 Environment startup_script。Memory version/redacted、Vault credentials 和 Session Credits 已有正式访问器。本轮没有新增 HTTP 操作;验证见 `DocumentationAlignmentTest`,真实环境结果单独记录。

契约核对:2026-10-28;接口清单更新:2026-10-29。**0.2.0** 与 Forward 更新一起提供 **107 个 Managed 协议操作:105 个普通 HTTP 操作、2 个 SSE 操作**。同步/异步/响应封装只计一个操作;0.1.0 不包含本表能力。

Base URL:CN `https://api.qoder.com.cn/api/v1/cloud`,Global `https://api.qoder.com/api/v1/cloud`。使用方法见 [Managed 指南](managed.md);验收范围见 [真实环境测试](managed-live-testing.md)。接口存在与离线通过不等于线上权限或执行结果已验证。
Expand Down
4 changes: 4 additions & 0 deletions docs/session-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,7 @@ To cancel the actual Session execution, use the existing `client.sessions().canc
[SessionStreamingExample](../examples/src/main/java/com/qoder/cloudagents/examples/SessionStreamingExample.java) supports existing Session or Thread, shared PAT/SA setup, sync/async consumption, a 20-event limit and a two-minute total deadline. It logs only event IDs/types, checkpoint and request ID. Tests run it as an independent consumer of the installed JAR.

Protocol behavior follows the [shared SDK baseline](forward-api-coverage.md). Offline checks do not establish CN/Global production compatibility; use [live testing](live-testing.md) to exercise Session execution and streaming in a selected environment.

Thread `resumableStream` stops after yielding `session.thread_status_terminated`. Idle events do not end a Thread subscription. A Session subscription continues after one child Thread terminates. This applies to both Forward and Managed, including asynchronous clients.

In both layers, `event.evaluatedPermission()` returns an optional `String` (`allow`, `ask`, or `deny`). Forward additionally exposes `event.evaluation()` for policy source and optional automatic decision details; check for null before reading `evaluatedPermission().reasonCode()`. The nested decision is an object and is separate from the event-level result.
17 changes: 17 additions & 0 deletions docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,20 @@ Implementation follows the [pinned SDK baseline](forward-api-coverage.md). Newer
Default tests cover local protocol behavior and independent JAR consumers. Session/Thread SSE and resumable subscriptions are described in [streaming](session-streaming.md); the [live runner](live-testing.md) separately checks actual Session execution.

See [Forward contract updates](forward-parity.md) for automatic Session metadata markers, Credits event accessors, and Memory Store mount pagination.

## Session budget and model overrides

```java
SessionCreateParams create = SessionCreateParams.builder()
.identityId("idn_existing").templateId("tmpl_existing")
.budget(SessionBudget.builder().type("limit").maxCreditCost("100.00").build())
.config(SessionConfig.builder().model(ModelConfig.builder()
.id("ultimate").speed("high").build()).build())
.build();
SessionUpdateParams clear = SessionUpdateParams.builder().budget(null)
.config(SessionConfig.builder().model(null).build()).build();
```

Omitting `budget` keeps the current limit on update; `null` clears it and an object replaces it. It limits cumulative model and sandbox Credits; an active turn can finish, but the server rejects the next user message once the budget is exhausted. A Session model override accepts a model ID string (`ModelConfig.of`) or an object. Omit it on create to inherit; `null` is only valid on update to restore inheritance. Query model capabilities before selecting speed or context windows.

`session.updated` carries only changed fields. Use `event.field("budget").isMissing()` versus `.isNull()` to distinguish an unchanged budget from a cleared limit. `session.usage` provides cumulative `modelCredits()`, `sandboxRuntimeCredits()` and `totalCredits()`; replace the previous snapshot rather than summing successive events.
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,48 @@ void examplesRunAgainstInstalledJarAndPreserveMetadata() throws Exception {
assertEquals("/api/v1/forward/models", server.takeRequest().getPath());
}
}

@Test
void newSessionContractsAreAvailableFromThePackagedJar() {
com.qoder.cloudagents.forward.SessionBudget budget =
com.qoder.cloudagents.forward.SessionBudget.builder()
.type("limit")
.maxCreditCost("100.00")
.build();
com.qoder.cloudagents.forward.SessionConfig config =
com.qoder.cloudagents.forward.SessionConfig.builder()
.model(com.qoder.cloudagents.forward.ModelConfig.of("ultimate"))
.build();
com.qoder.cloudagents.forward.SessionUpdateParams params =
com.qoder.cloudagents.forward.SessionUpdateParams.builder()
.budget(budget)
.config(config)
.build();
assertEquals("100.00", params.budget().maxCreditCost());
assertEquals("ultimate", params.config().model().id());
com.qoder.cloudagents.core.JsonValue payload =
com.qoder.cloudagents.core.JsonValue.parse(
"{\"evaluated_permission\":\"deny\",\"evaluation\":{\"type\":\"auto\",\"evaluated_permission\":{\"type\":\"deny\",\"reason_code\":\"high_risk\"}}}");
com.qoder.cloudagents.forward.SessionEvent event =
new com.qoder.cloudagents.forward.SessionEvent(payload, null);
assertEquals("deny", event.evaluatedPermission());
assertEquals("high_risk", event.evaluation().evaluatedPermission().reasonCode());
assertEquals(
"deny",
new com.qoder.cloudagents.managed.SessionEvent(payload, null).evaluatedPermission());
assertEquals(
"echo ready",
com.qoder.cloudagents.forward.EnvironmentConfig.builder()
.type("cloud")
.startupScript("echo ready")
.build()
.startupScript());
assertEquals(
"echo ready",
com.qoder.cloudagents.managed.EnvironmentConfig.builder()
.type("cloud")
.startupScript("echo ready")
.build()
.startupScript());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ public String type() {
return optional("type", JsonValue::asString);
}

/** Runs on each sandbox start or resume; self-hosted execution depends on the worker. */
public String startupScript() {
return optional("startup_script", JsonValue::asString);
}

public String setupScript() {
return optional("setup_script", JsonValue::asString);
}
Expand All @@ -36,6 +41,11 @@ public Builder type(String value) {
return put("type", value);
}

/** Configure a startup script; the SDK does not execute it. */
public Builder startupScript(String value) {
return put("startup_script", value);
}

public Builder setupScript(String value) {
return put("setup_script", value);
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/qoder/cloudagents/forward/Session.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ public String title() {
return optional("title", JsonValue::asString);
}

/** Null means no limit; field("budget") distinguishes omission from explicit null. */
public SessionBudget budget() {
return optional("budget", SessionBudget::fromJson);
}

public Map<String, JsonValue> metadata() {
return optionalMap("metadata", item -> item);
}
Expand Down
53 changes: 53 additions & 0 deletions src/main/java/com/qoder/cloudagents/forward/SessionBudget.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.qoder.cloudagents.forward;

import com.qoder.cloudagents.core.*;
import com.qoder.cloudagents.errors.ConfigurationException;
import java.math.BigDecimal;
import java.util.*;

/** Immutable JSON configuration; unknown fields are preserved. */
public final class SessionBudget extends JsonObject {
private SessionBudget(JsonValue value) {
super(value);
}

public static SessionBudget fromJson(JsonValue value) {
return new SessionBudget(value);
}

public String type() {
return optional("type", JsonValue::asString);
}

public String maxCreditCost() {
return optional("max_credit_cost", JsonValue::asString);
}

public static Builder builder() {
return new Builder();
}

public static final class Builder extends JsonObjectBuilder<Builder> {
private Builder() {}

public Builder type(String value) {
return put("type", value);
}

public Builder maxCreditCost(String value) {
return put("max_credit_cost", value);
}

public SessionBudget build() {
require("type", "max_credit_cost");
if (!"limit".equals(json().get("type").asString()))
throw new ConfigurationException("Budget type must be limit");
String cost = json().get("max_credit_cost").asString();
if (!cost.matches("[0-9]{1,12}(\\.[0-9]{1,2})?") || new BigDecimal(cost).signum() <= 0)
throw new ConfigurationException(
"maxCreditCost must be a positive decimal string with at most 12 integer and 2"
+ " fractional digits");
return new SessionBudget(json());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ public static SessionConfig fromJson(JsonValue value) {
return new SessionConfig(value);
}

public ModelConfig model() {
return optional("model", ModelConfig::fromJson);
}

public Map<String, String> environmentVariables() {
return optionalMap("environment_variables", JsonValue::asString);
}
Expand All @@ -24,6 +28,11 @@ public static Builder builder() {
public static final class Builder extends JsonObjectBuilder<Builder> {
private Builder() {}

/** Omit on create to inherit; null on update restores inheritance. */
public Builder model(ModelConfig value) {
return put("model", value);
}

public Builder environmentVariables(Map<String, String> value) {
return put("environment_variables", value);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public String title() {
return optional("title", JsonValue::asString);
}

/** Null means no limit; field("budget") distinguishes omission from explicit null. */
public SessionBudget budget() {
return optional("budget", SessionBudget::fromJson);
}

public Map<String, JsonValue> metadata() {
return optionalMap("metadata", item -> item);
}
Expand Down Expand Up @@ -56,6 +61,11 @@ public Builder title(String value) {
return put("title", value);
}

/** Omit to keep the default/current budget; null clears it. */
public Builder budget(SessionBudget value) {
return put("budget", value);
}

public Builder metadata(Map<String, JsonValue> value) {
return put("metadata", value);
}
Expand Down
14 changes: 12 additions & 2 deletions src/main/java/com/qoder/cloudagents/forward/SessionEvent.java
Original file line number Diff line number Diff line change
Expand Up @@ -182,14 +182,24 @@ public Map<String, JsonValue> error() {
return optionalMap("error", item -> item);
}

public Map<String, JsonValue> evaluatedPermission() {
return optionalMap("evaluated_permission", item -> item);
/** Approval result: allow, ask, or deny. */
public String evaluatedPermission() {
return optional("evaluated_permission", JsonValue::asString);
}

public ToolApprovalEvaluation evaluation() {
return optional("evaluation", ToolApprovalEvaluation::fromJson);
}

public Map<String, JsonValue> agent() {
return optionalMap("agent", item -> item);
}

/** Null means no limit; field("budget") distinguishes omission from explicit null. */
public SessionBudget budget() {
return optional("budget", SessionBudget::fromJson);
}

public Map<String, JsonValue> metadata() {
return optionalMap("metadata", item -> item);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ static EventStream<SessionEvent> create(
SessionEvent::new,
event ->
"session.status_terminated".equals(event.type())
|| "session.deleted".equals(event.type()),
|| "session.deleted".equals(event.type())
|| (thread && "session.thread_status_terminated".equals(event.type())),
resumable);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ public String title() {
return optional("title", JsonValue::asString);
}

/** Null means no limit; field("budget") distinguishes omission from explicit null. */
public SessionBudget budget() {
return optional("budget", SessionBudget::fromJson);
}

public Map<String, JsonValue> metadata() {
return optionalMap("metadata", item -> item);
}
Expand All @@ -36,6 +41,11 @@ public Builder title(String value) {
return put("title", value);
}

/** Omit to keep the default/current budget; null clears it. */
public Builder budget(SessionBudget value) {
return put("budget", value);
}

public Builder metadata(Map<String, JsonValue> value) {
return put("metadata", value);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.qoder.cloudagents.forward;

import com.qoder.cloudagents.core.*;

/** Automatic approval decision; reasonCode may be absent. Unknown fields are preserved. */
public final class ToolApprovalDecision extends JsonObject {
private ToolApprovalDecision(JsonValue value) {
super(value);
}

public static ToolApprovalDecision fromJson(JsonValue value) {
return new ToolApprovalDecision(value);
}

public String type() {
return optional("type", JsonValue::asString);
}

public String reasonCode() {
return optional("reason_code", JsonValue::asString);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.qoder.cloudagents.forward;

import com.qoder.cloudagents.core.*;

/** Optional policy evaluation details on tool-use events. Unknown fields are preserved. */
public final class ToolApprovalEvaluation extends JsonObject {
private ToolApprovalEvaluation(JsonValue value) {
super(value);
}

public static ToolApprovalEvaluation fromJson(JsonValue value) {
return new ToolApprovalEvaluation(value);
}

public String type() {
return optional("type", JsonValue::asString);
}

public ToolApprovalDecision evaluatedPermission() {
return optional("evaluated_permission", ToolApprovalDecision::fromJson);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ public String type() {
return optional("type", JsonValue::asString);
}

/** Runs on each sandbox start or resume; self-hosted execution depends on the worker. */
public String startupScript() {
return optional("startup_script", JsonValue::asString);
}

public String setupScript() {
return optional("setup_script", JsonValue::asString);
}
Expand All @@ -40,6 +45,11 @@ public Builder type(String value) {
return put("type", value);
}

/** Configure a startup script; the SDK does not execute it. */
public Builder startupScript(String value) {
return put("startup_script", value);
}

public Builder setupScript(String value) {
return put("setup_script", value);
}
Expand Down
Loading
Loading