spring-agent-scope
Health Pass
- License — License: Apache-2.0
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Community trust — 77 GitHub stars
Code Pass
- Code scan — Scanned 8 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
A Spring Boot agent harness built on AgentScope Java 2.0 and JDK 25: multi-turn chat over HTTP, a fresh agent per request, shared session history.
English | 简体中文
About Yoke
Yoke · AgentHarness: a monolithic agent service built on Spring Boot and AgentScope Java (V2).
A Spring-based agent harness.
Yoke is an open-source project of 友客AI (YouKe AI). We read the AgentScope Java V2 and Spring source code together and learn by building an agent service that really runs. A yoke is the wooden frame that makes oxen and horses pull in one direction. Yoke wraps around the model and keeps it working reliably.
- Building an agent with AgentScope Java: the demo runs in a dozen lines. Serving real users is where it gets hard. Should each request get a new agent, or share one global agent? Where does the conversation history live?
- Streaming, tool calls, multi-turn sessions and tight permissions are scattered across the official examples. Fitting them into one Spring Boot project, deciding how to layer it and which boundaries must hold, is left to you.
- The official starters are one line away, but their endpoints have no authentication and the agent is a singleton, so shipping them as they are mixes sessions of different users. This project gives a judgement on what to use and what to write yourself.
- The project starts from an endpoint that can hold a conversation and solves one engineering problem per lesson. The code is open source and runs as is. Limits that were not verified and pitfalls hit along the way are written down too.
Features
- One endpoint for multi-turn conversations:
POST /api/chat, plusPOST /api/chat/streamthat streams the reasoning process over SSE using the AG-UI protocol. - A built-in
get_weathertool looks up current weather through the open-source Open-Meteo API, no key needed (the free tier is for non-commercial use only). - Every request builds a temporary
HarnessAgentand closes it afterwards. Conversation history lives in Redis, so it survives restarts and is shared by all replicas, isolated per(userId, sessionId), and compacted automatically when a conversation gets long. - Apart from
get_weatherthe agent has no tools: no file, shell or web access, and sub-agents are switched off. A logging middleware records a timing trace for every call. - Models are plugged in through the official AgentScope starters: OpenAI-compatible APIs (works with DashScope, DeepSeek and others), Anthropic, Gemini and Ollama. Switching is a dependency and configuration change.
local/test/prodprofiles. In production, a missing API key fails the startup instead of shipping with an empty key.- Virtual threads are enabled by default, for both Tomcat and Spring's default thread pool.
Getting Started
Requires JDK 25 and Docker (for the local Redis).
docker compose up -d # start Redis first; the app refuses to start without it
sdk env # switch to JDK 25 (if you use sdkman)
export ARK_API_KEY=... # the local profile talks to Volcengine Ark (OpenAI-compatible)
./gradlew bootRun # the local profile is active by default
curl -X POST localhost:8080/api/chat \
-H 'Content-Type: application/json' \
-d '{"message": "Hello"}'
# {"sessionId":"…","reply":"…"}
Send the returned sessionId with later requests to continue the same conversation. Run the tests with ./gradlew build; tests that need a real Redis start it from the same docker-compose.yml through Testcontainers, and are skipped with a clear reason when Docker is not available (with CI=true they fail instead).
Sessions
History is stored per (userId, sessionId). Both are optional in the request body ({"userId": "alice", "sessionId": "s-1", "message": "Hello"}), and the server always makes sure both have a value before the agent sees them:
- both given: that pair
- only
sessionId: it belongs to thedefaultuser (same as older clients) - only
userId: a new session is created for that user, and its id is returned in the response - neither: a new session under the
defaultuser
A half-empty pair is never passed on to the framework, because in AgentScope 2.0.4 a missing sessionId falls into one shared session named after the agent, and a missing userId falls into an anonymous user, which would mix everyone's history together. Both ids may only contain letters, digits, _, . and - (1 to 64 characters, not all dots, so . and .. are rejected); anything else returns 400. The framework joins them as userId/sessionId, so allowing / would let different pairs collide on one slot, and userId also becomes a workspace directory name. There is no authentication yet: userId is whatever the caller says, so it is not an identity check.
What is stored in Redis, per session (checked against a real model, two turns with one tool call):
| Key | Type | Content |
|---|---|---|
yoke:session:{userId}/{sessionId}:agent_state |
String | One JSON document with the whole agent state. The conversation is its context array (one entry per message: USER, ASSISTANT with tool_use, TOOL with tool_result, ...), next to a summary field and a few empty context objects. summary stayed empty in a real compaction run: the compaction summary is stored as the first message of context instead. About 3.3 KB for two turns |
yoke:session:{userId}/{sessionId}:agent_state:ver |
String | Version number used for optimistic concurrency, bumped on every save |
yoke:session:{userId}/{sessionId}:_keys |
Set | Names of all state keys of the session (only agent_state here), used to delete a whole session |
The model's thinking blocks and per-message token usage are stored too, and the whole document is read and written back on every turn, so it grows with the conversation. There is no separate message list key.
Streaming (AG-UI)
POST /api/chat/stream speaks the AG-UI protocol: the request body is a standard RunAgentInput, and the response is a text/event-stream of AG-UI events pushed while the agent is reasoning. Use curl -N to turn off buffering:
curl -N -X POST localhost:8080/api/chat/stream \
-H 'Content-Type: application/json' \
-d '{"threadId": "demo", "runId": "run-1", "messages": [{"id": "msg-1", "role": "user", "content": "What is the weather in Beijing?"}]}'
Request rules (any violation returns 400 before the stream starts):
Content-Typemust beapplication/json, otherwise 415. This also stops a cross-site page from starting a run with atext/plainrequest that skips the CORS preflightthreadIdis the session ID and is required: letters, digits and_.-, 1 to 64 characters, not all dots. It shares the same history assessionIdofPOST /api/chatof thedefaultuser (RunAgentInputhas no user field, so the streaming endpoint always runs asdefault)runIdis required and must not be blank (RunAgentInputrequires it)messagesmust not be empty, every message needs anidand arole, and the last one must be ausermessage with non-blank plain text.contentmust be a string: content blocks (even text-only ones, or with images) are rejected- The server keeps the history itself and only uses the last
usermessage. Earlier messages,tools,context,state,forwardedPropsandresumeare ignored, so a client cannot add tools to the agent or rewrite the history. Only the text of that message is kept: itsid,toolCallsandtoolCallIdnever reach the history, and the message ID is generated on the server. The adapter is also configured withToolMergeMode.AGENT_ONLY, so only server-registered tools are ever used
Illustrative output (values are made up, field names come from real runs against a stub model, long fields are elided). Only data: lines are sent; the event type is the type field in the JSON, there is no SSE event: name:
data:{"type":"RUN_STARTED","threadId":"demo","runId":"…","input":{…}}
data:{"type":"TOOL_CALL_START","threadId":"demo","runId":"…","toolCallId":"call_1","toolCallName":"get_weather"}
data:{"type":"TOOL_CALL_ARGS","threadId":"demo","runId":"…","toolCallId":"call_1","delta":"{\"city\":\"Beijing\"}"}
data:{"type":"TOOL_CALL_END","threadId":"demo","runId":"…","toolCallId":"call_1"}
data:{"type":"TOOL_CALL_RESULT","threadId":"demo","runId":"…","toolCallId":"call_1","content":"\"Sunny, 25 C\"","role":"tool","messageId":"…"}
data:{"type":"TEXT_MESSAGE_START","threadId":"demo","runId":"…","messageId":"…","role":"assistant"}
data:{"type":"TEXT_MESSAGE_CONTENT","threadId":"demo","runId":"…","messageId":"…","delta":"It is sunny, 25 C."}
data:{"type":"TEXT_MESSAGE_END","threadId":"demo","runId":"…","messageId":"…"}
data:{"type":"CUSTOM","threadId":"demo","runId":"…","name":"token_usage","value":{"delta":{…},"cumulative":{"inputTokens":83,"outputTokens":27,…},"modelCall":{…}}}
data:{"type":"RUN_FINISHED","threadId":"demo","runId":"…"}
Event type |
Meaning |
|---|---|
RUN_STARTED |
Always the first event, carries threadId and runId |
TEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT / TEXT_MESSAGE_END |
Generated text; concatenate the delta of the CONTENT events in order to get the reply |
TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END |
A tool the model decided to call; TOOL_CALL_ARGS carries fragments of the arguments JSON and only appears when the model streams the arguments in pieces |
TOOL_CALL_RESULT |
The result of a tool, matched to the call by toolCallId |
CUSTOM with name = token_usage |
Token usage after each model call, with delta and cumulative |
CUSTOM with name = hint |
A hint the framework adds during reasoning, value has source and text |
RUN_FINISHED |
The normal end |
RUN_ERROR |
Reasoning failed; the HTTP status is still 200 and the stream ends normally instead of dropping the connection. The message is the raw exception message and is not redacted, so do not show it to end users directly |
Every stream starts with RUN_STARTED, even when the agent cannot be created, and ends with either RUN_FINISHED or RUN_ERROR, never both. When the agent fails while running, the AG-UI adapter maps the exception to code (TIMEOUT_ERROR, INTERRUPTED_ERROR, INVALID_INPUT_ERROR or INTERNAL_ERROR); failures outside the adapter, such as the agent not being created, are always INTERNAL_ERROR. There is no done event and the final text is not repeated: the client concatenates TEXT_MESSAGE_CONTENT. The temporary agent is closed when the stream completes, fails or the client disconnects. The adapter's runTimeout setting is not read by AgentScope 2.0.4, so it does not limit the run time.
Breaking change
The earlier private events (textDelta,toolCall,toolResult,hint,done,error) and the{"sessionId", "message"}request body of the streaming endpoint are gone. The streaming endpoint now only acceptsContent-Type: application/json; requests without aContent-Type, or withtext/plainorapplication/*+json, get 415.POST /api/chatnow validatessessionIdwith the same rule asthreadId(non-blank values likeuser:42used to be accepted and now return 400); a blanksessionIdstill starts a new session.
Note
Concurrent requests for the same session are not serialized, so avoid sending them at the same time.HarnessAgent.close()waits for the framework's global async-write quiescence (up to 5 seconds each for two waits) inside the per-request cleanup, so requests are coupled through it; this has not been load-tested.
Configuration
Configuration is split by profile. The local profile is active when none is specified; switch with the SPRING_PROFILES_ACTIVE=prod environment variable or the --spring.profiles.active=prod command-line argument.
application.yml: Shared by all profiles: model, agent, virtual threads, log format, workspace, key prefix and compactionapplication-local.yml: Local development: Volcengine Arkbase-urland model name live in the file, the API key comes fromARK_API_KEY(may be empty), Redis atredis://127.0.0.1:6379(the one fromdocker-compose.yml),com.yokelogs at debugapplication-test.yml: Automated tests: a fake API key, the model is never really called; the Redis address is injected by the testsapplication-prod.yml: Production: the API key and the Redis address (YOKE_REDIS_URI) must come from the environment, startup fails without them; graceful shutdown
The model is created by the official AgentScope starter from agentscope.*, and a HarnessAgent is built per request by HarnessAgentFactory (the official singleton agent is turned off):
| Property | Description |
|---|---|
agentscope.openai.api-key |
Model API key; local reads ARK_API_KEY, prod reads OPENAI_API_KEY |
agentscope.openai.model-name |
Model name |
agentscope.openai.base-url |
Set this to use another OpenAI-compatible service (DashScope, DeepSeek, ...) |
agentscope.agent.sys-prompt |
System prompt |
agentscope.agent.max-iters |
Maximum number of ReAct iterations |
Yoke's own settings use the yoke.* prefix:
| Property | Description |
|---|---|
yoke.redis.uri |
Redis connection string. No default in prod (YOKE_REDIS_URI); the application fails at startup if Redis cannot be reached |
yoke.redis.key-prefix |
Prefix of every Redis key, default yoke: |
yoke.compaction.trigger-messages |
Compact the conversation once it reaches this many messages, default 30 |
yoke.compaction.keep-messages |
Messages kept verbatim after a compaction, default 10. Values <= 0, or a keep count not smaller than the trigger count, fall back to the defaults |
yoke.workspace |
HarnessAgent workspace for local files (offloaded messages, memory), default ~/.yoke/workspace; it stays on the machine and is not shared between replicas |
The agent is built without file, shell or web tools, and sub-agents are disabled as a whole: reading the 2.0.4 source shows that a sub-agent inherits only the file and shell switches, not the web switch, so leaving sub-agents on would let the model spawn one that can fetch arbitrary URLs. The read-side memory and session tools (memory_get, memory_search, session_history, session_list, session_search) are denied too: memory_get only checks that a path stays inside the workspace root, not inside the current user's directory, and session_history puts model-supplied IDs straight into a path, so they would let the model read other users' memory and sessions. What remains is get_weather, memory_save (only appends to the fixed MEMORY.md and memory/<date>.md in the workspace, without a size limit), load_skill_through_path and wait_async_results. The tool config is passed in explicitly, so a tools.json in the workspace has no effect. A MiddlewareBase bean is attached to every new agent; LoggingMiddleware logs agent, model and tool timings without tool arguments or outputs. OpenTelemetry tracing is not attached by default: add an OpenTelemetry SDK and exporter and register an OtelTracingMiddleware bean when you need it.
To use another model, replace agentscope-openai-spring-boot-starter in build.gradle with the matching starter (dashscope, anthropic, gemini or ollama) and change agentscope.model.provider.
Project Structure
src/main/java/com/yoke/
├── YokeApplication.java Application entry point
├── api/ HTTP contract (ChatApi: paths, validation and docs live here)
├── codec/ Conversion between domain types and framework types (MessageCodec, AguiStreamCodec, HintEventConverter)
├── config/ Agent, Redis and AG-UI adapter configuration (HarnessAgentFactory, YokeProperties)
├── controller/ Contract implementation (ChatController)
├── middleware/ Agent middlewares (LoggingMiddleware)
└── model/ Request and response objects (ChatRequest, ChatResponse)
src/main/resources/ application.yml and the per-profile configuration
docker-compose.yml Local Redis, also used by the integration tests
docs/brand/ Logo
Learn More
- AgentScope Java documentation: the full guide to agents, tools, permissions, state stores and more.
Contributing
Issues and pull requests are welcome. AgentScope message and event types must not leak out of codec (and middleware, whose hook signatures need them); ArchitectureTests enforces this. Before submitting, run ./gradlew build and make sure all tests pass. Code comments are written in Chinese.
License
Yoke is open-sourced software licensed under the Apache License 2.0.
Star History
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found