Author SHA1 Message Date
slhaf 9398581aa4 fix(core): correct logback log level fallback syntax 2026-05-09 14:47:46 +08:00
slhaf 8cb876b532 feat(partnerctl): add --log-level option for run command and wire runtime log level to Partner-Core 2026-05-09 14:12:32 +08:00
slhaf d72b34acfd fix(partnerctl): align Partner-Core jar path to resources/partner-core.jar across init/control 2026-05-09 13:38:51 +08:00
slhaf 79dcda082f refactor(partnerctl-chat): optimize color of chat prompt 2026-05-09 10:14:36 +08:00
slhaf fbd1d17fc4 refactor(partnerctl): extract TerminalText styling utility and reuse it in prompt/chat rendering 2026-05-08 23:44:08 +08:00
slhaf a8e3a84db8 feat(partnerctl): implement interactive WebSocket chat client with live event rendering 2026-05-08 23:16:49 +08:00
slhaf e6e0eef161 refactor(interaction-api): extract interaction data models into new Partner-Interaction-Api module and update imports/dependencies 2026-05-08 20:32:11 +08:00
slhaf 4c042769f2 refactor(partnerctl): disable Config and Module command temporally in root command 2026-05-06 15:18:20 +08:00
slhaf 7bee14452e feat(partnerctl): add shared help option mixin across commands 2026-05-06 15:06:20 +08:00
slhaf 442397c1f5 feat(partnerctl): i18n-ize command and option descriptions via message bundles 2026-05-06 14:55:16 +08:00
slhaf 0b329aa370 docs(readme): update build/run commands for Partner-Core module and jar naming 2026-05-06 13:55:14 +08:00
slhaf ffc96bbb64 refactor(partnerctl): split control commands into dedicated classes and extract shared runtime utilities 2026-05-06 13:26:46 +08:00
slhaf 8398c14794 feat(partnerctl): add background run mode and log command with pid/log file management 2026-05-05 23:57:32 +08:00
slhaf bd4d5164d3 feat(partnerctl): add run and shutdown commands with process lifecycle handling 2026-05-05 23:39:17 +08:00
slhaf 9073f88117 feat(partnerctl-init): add i18n message bundles for init flow and source build output 2026-05-05 22:37:41 +08:00
slhaf 0710f60ed6 fix(partnerctl-init): use numeric default for heartbeat interval prompt 2026-05-05 17:33:44 +08:00
slhaf 1bf02f543e feat(partnerctl-init): add safe Partner Home selection with overwrite/cancel flow 2026-05-05 17:21:02 +08:00
slhaf 19f87c93e3 feat(partnerctl): add ANSI-styled interactive prompt output and labels 2026-05-05 16:36:59 +08:00
slhaf 828ddff2c2 chore(log): remove legacy log output 2026-05-05 01:29:12 +08:00
slhaf 57b23e0dcb feat(partnerctl-init): add finalize step 2026-05-05 00:41:25 +08:00
slhaf 11f7200e23 feat(partnerctl-init): add interactive model provider configuration 2026-05-04 23:52:13 +08:00
slhaf ee8f568006 refactor(partnerctl): move GatewayConfig into commands.data model file 2026-05-04 20:35:36 +08:00
slhaf 393dcff6df feat(partnerctl-init): add interactive gateway configuration and gateway.json generation 2026-05-04 17:48:58 +08:00
slhaf 0fdc0038a5 docs(partnerctl-support): add KDoc for source build spec and external module manifest models 2026-05-04 17:47:20 +08:00
slhaf 97bf0618f4 feat(partnerctl): add details output helper for key-value prompt sections 2026-05-04 17:08:20 +08:00
slhaf 4200df6525 feat(partnerctl-support): add external module manifest models and gateway/module loaders 2026-05-04 15:20:34 +08:00
slhaf 686dc76b55 chore(partnerctl-pom): add kotlinx serialization 2026-05-03 23:30:26 +08:00
slhaf adab925e7d refactor(partnerctl-init): extract source build/install flow into reusable support helper 2026-05-03 21:50:25 +08:00
slhaf 6c74de2126 feat(partnerctl-init): add source build install choice 2026-05-03 21:06:46 +08:00
slhaf 7fcdb1c671 refactor(partnerctl-exception): add command interrupted exception, and unify exception handling 2026-05-03 20:08:18 +08:00
slhaf 78877f14d5 feat(partnerctl-init): support initialize Partner Home 2026-05-03 19:35:43 +08:00
slhaf 729700ceb7 feat(partnerctl): add path prompt with completion and validation 2026-05-03 19:27:07 +08:00
slhaf 6f48c36f67 feat(partnerctl): add interactive single select 2026-05-03 18:45:03 +08:00
slhaf 551a2a16d9 feat(partnerctl): add command execution helpers with captured and inherited IO 2026-05-03 18:34:01 +08:00
slhaf 40af7a4de6 feat(partnerctl): add Prompt UI helper with ask/confirm/select and multi-select support 2026-05-03 17:38:56 +08:00
slhaf cbdb33fefe chore(partnerctl): add GraalVM native-image setup and arm64 build tooling 2026-05-03 15:46:26 +08:00
slhaf d2c3416f48 feat(partnerctl): add main command with init/run/module and chat demo subcommands 2026-05-03 15:44:53 +08:00
slhaf c3dc8180f1 feat(partnerctl): initialize PartnerCtl maven module with CLI dependencies 2026-05-02 23:35:39 +08:00
slhaf 1564c7d5e1 refactor(gateway): provide websocket as default agent gateway 2026-05-02 22:46:14 +08:00
slhaf 9e33f16aae fix(onebot): flush buffered replies on terminal events 2026-05-02 21:55:28 +08:00
slhaf c84d88eab7 refactor(communication): simplify reply consumer buffering 2026-05-02 21:55:11 +08:00
slhaf 692c601f17 refactor(model): expose stream chat lifecycle callbacks 2026-05-02 21:54:48 +08:00
slhaf 2a8b2aff7b test(onebot): add test class for OneBot adapter 2026-05-02 16:32:49 +08:00
slhaf afb896e6db refactor(context): support assign response channel while running flow context creating 2026-05-02 16:32:25 +08:00
slhaf 2b575df3f9 fix(agent): add url class loader to support load external modules 2026-05-02 00:36:34 +08:00
slhaf db18c818ac chore(pom): add kotlin-maven-plugin setup 2026-05-02 00:16:19 +08:00
slhaf 973875b2e7 feat(onebot): add OneBot v11 reverse websocket adapter 2026-05-02 00:02:22 +08:00
slhaf 018b524be3 refactor(gateway): remove unused abstract methods 2026-05-01 22:09:16 +08:00
slhaf 843549dadf chore(gateway): update log output in websocket gateway 2026-05-01 21:43:40 +08:00
slhaf 32f0c5c6a8 feat(pom): add Partner-Onebot-Adapter module and Maven config 2026-05-01 21:11:40 +08:00
slhaf dd31f6a20d chore(idea): update project dictionary and encodings 2026-05-01 21:11:13 +08:00
slhaf 178c5138f7 refactor(gateway): remove duplicate response channel register 2026-05-01 21:10:13 +08:00
slhaf f9cdf164f3 feat(pom): create Partner-External-Modules 2026-04-30 22:55:20 +08:00
slhaf 0c2843a889 chore(gitignore): update gitignore 2026-04-30 22:50:43 +08:00
slhaf 572bedcd18 refactor(pom): align Maven coordinates to work.slhaf.partner and lowercase artifactIds 2026-04-30 22:45:08 +08:00
slhaf 32ee6b5ed6 docs(architecture): update startup/register-chain flow for AgentBootstrap and registerContext 2026-04-30 22:24:19 +08:00
slhaf 428f133ac3 refactor(agent): introduce AgentBootstrap for startup wiring and simplify app launch 2026-04-30 22:12:04 +08:00
slhaf 7aab236221 docs(readme): update readme 2026-04-30 16:15:55 +08:00
slhaf 0d456596a8 docs(readme): update readme 2026-04-30 16:12:10 +08:00
slhaf cbd926bd41 docs(memory): add document for memory 2026-04-30 16:08:18 +08:00
slhaf 4a00e5f320 docs(action): add document for action 2026-04-29 22:27:27 +08:00
slhaf 690f258dc9 docs(context): add document for context workspace 2026-04-29 11:03:03 +08:00
slhaf d7b271bf71 docs(model): add model provider runtime/config docs and link from README 2026-04-28 23:00:23 +08:00
slhaf ff80d6bbc4 docs(config): add config center lifecycle docs and split config reference into dedicated doc 2026-04-28 22:13:12 +08:00
slhaf 3e73d574df docs(readme): update readme 2026-04-28 21:12:08 +08:00
slhaf 9ea475432f docs(architecture): add startup/register/runtime/shutdown flow docs and architecture overview 2026-04-28 21:09:33 +08:00
slhaf e66ed9e9c0 refactor(trace): move sink registration to TraceSink default methods and restrict registry APIs 2026-04-28 14:58:09 +08:00
slhaf 73f6ff2745 refactor(trace): decouple recorder from file persistence
Turn TraceRecorder into a lightweight trace event entry point and move
file persistence responsibilities into the default FileTraceSink. Trace
events are now published through TraceSinkRegistry, allowing additional
runtime observers to subscribe without parsing trace files.

Add TraceSink and TraceSinkRegistry, keep FileTraceSink registered as the
default sink, and preserve the existing active/historical/archived trace
file rotation behavior inside the file sink.

Also change TraceEvent to carry a logical key instead of a caller-provided
path, so trace storage locations are resolved internally under the traceroot. Update existing trace producers to emit logical keys such ascontext-workspace, exception, and advice targets.
2026-04-27 23:53:20 +08:00
slhaf 3eac52f4e2 refactor(action): switch builtin actions to provider-driven self-registration 2026-04-27 20:53:45 +08:00
slhaf cf61c171a5 fix(action): stabilize staged action execution metadata
Use a stage-local Phaser in ActionExecutor so each action-chain stage
waits on its own execution phase. This avoids reusing a terminated
Phaser after the first stage completes, which could cause later stages
to advance without actually waiting for their MetaActions and trigger
FINAL_CHECK prematurely.

Add stage-level descriptions to evaluated action chains and pass them
through execution/correction contexts. Stage descriptions are now aligned
with normalized action-chain order, exposed to ParamsExtractor, persisted
in action state, and included in CorrectorInput so repeated MetaActions
can be parameterized and corrected according to their concrete stage
objective.

Also fix APPEND intervention semantics to append after the requested
stage instead of overwriting or dropping stages, and include stage
descriptions in correction prompts to distinguish stage intent from
generic MetaAction capability descriptions.
2026-04-27 18:41:32 +08:00
slhaf 822ea82593 feat(action): add stage descriptions to action chains 2026-04-27 17:54:38 +08:00
slhaf f957d7caa4 fix(action): continue execution after final correction 2026-04-27 17:01:50 +08:00
slhaf 9be499eb64 refactor(action): optimize prompt of ActionExtractor 2026-04-27 17:01:38 +08:00
slhaf 184c396752 fix(action): stabilize scheduler wheel lifecycle 2026-04-27 16:25:42 +08:00
slhaf 04ceccc46f fix(action): clarify pending action intention context 2026-04-27 15:51:42 +08:00
slhaf b0185c65c8 chore(action): remove unused variable 2026-04-27 15:34:48 +08:00
slhaf d43476864c docs: update README 2026-04-27 15:07:55 +08:00
slhaf e022f134ac docs: update README and add documentation skeleton 2026-04-27 15:03:03 +08:00
slhaf 9144cd90ce refactor(project): remove Partner-Common module and move InProcessMcpTransport into Partner-Core 2026-04-26 19:45:38 +08:00
slhaf c23bda430f refactor(project): remove Partner-SandboxRunner module and clean related files 2026-04-26 18:20:07 +08:00
slhaf aa98c29744 refactor(communication): optimize prompt to avoid unexpected output in communication producer 2026-04-26 16:38:13 +08:00
slhaf eade39328a refactor(memory-selector): refactor memory recalling into async worker 2026-04-25 23:22:43 +08:00
slhaf 94adf9a368 fix(openai-provider): add cached structured-output downgrade to prompt-only JSON fallback 2026-04-25 22:12:07 +08:00
slhaf dd5ab3aaf3 fix(openai-provider): generate and inject response-type JSON shape instruction for structured chat 2026-04-25 20:23:28 +08:00
slhaf 075a8ece3e refactor(action-planner): optimize prompts and initiate-turn input 2026-04-25 19:31:51 +08:00
slhaf 4bc2e4adbf refactor(action-executor): add FINAL_CHECK fallback correction at chain end and pass check mode/result in corrector input 2026-04-25 16:56:47 +08:00
slhaf 9240ed44c4 refactor(action-evaluator): optimize prompt 2026-04-25 14:41:56 +08:00
slhaf 878fe4dc10 refactor(action-planner): simplify tendencies state hint naming and always follow up on refused candidates 2026-04-25 13:17:23 +08:00
slhaf 03bae6424e fix(openai-provider): ensure structured chat adds JSON instruction when missing 2026-04-25 00:11:57 +08:00
slhaf a14e123ff6 fix(communication): rolling out all chat messages on auto triggered and raise dialog rolling trigger length limit 2026-04-24 23:20:56 +08:00
slhaf d4277aed33 chore(config-center): downgrade skipped reload deletion log from info to
debug
2026-04-24 23:08:58 +08:00
slhaf e37dc7bc9b refactor(action-executor): emit action-finished state hint and trigger cognition turn on executable completion 2026-04-24 22:28:25 +08:00
slhaf 51706eb55e refactor(action-planner): replace pending confirm blocks with StateHintContent and streamline tendency turns 2026-04-24 22:04:02 +08:00
slhaf eb3e5f3f34 refactor(context): add StateHintContent to fit information exchange between different modules with communication producer 2026-04-23 22:01:10 +08:00
slhaf 164078bf42 refactor(action-evaluator): optimize prompt 2026-04-22 15:41:53 +08:00
slhaf 13d04f85e2 fix(action): correct behavior of resume intervention 2026-04-21 21:38:04 +08:00
slhaf 3a5d0f1cb4 refactor(action): optimize builtin actions descriptions and param explanations 2026-04-21 21:31:23 +08:00
slhaf aa05d3817b fix(action-evaluator): fix content of available-meta-actions block 2026-04-21 17:23:08 +08:00
slhaf 45f7d1c51e refactor(action): optimize resolved context domains 2026-04-21 17:21:59 +08:00
slhaf 3d56966381 fix(action): fix unexpected scheduled action losing 2026-04-21 17:12:44 +08:00
slhaf de340029c6 refactor(action): optimize prompts 2026-04-21 16:56:24 +08:00
slhaf 96101de227 refactor(trace): trace full snapshot of context workspace on changes 2026-04-21 16:48:21 +08:00
slhaf 2927cb2b6f fix(config): fix config reloading errors that caused by using wrong relative paths to load config file 2026-04-21 15:07:35 +08:00
slhaf 4af8feb178 fix(memory): enhance null-safe in memory-related modules 2026-04-21 09:18:49 +08:00
slhaf 43c2c648ac refactor(framework): support inject Standalone module into Sub module 2026-04-21 09:05:51 +08:00
slhaf a64c8f6c97 feat(exception): trace exception log in LogExceptionReporter 2026-04-21 08:49:05 +08:00
slhaf 406b7ba831 refactor(action): update watcher self-turn message for immediate action 2026-04-21 08:48:25 +08:00
slhaf 9c902e68d0 fix(dto): add NoArgsConstructor to support formatted chat 2026-04-20 23:10:39 +08:00
slhaf 4e894384bf refactor(block): optimize state action triggered block fading factor 2026-04-20 23:08:23 +08:00
slhaf a5cee79a8f fix(state): enable first-time state file save and block overwrites until explicit load 2026-04-20 22:40:39 +08:00
slhaf d1da99c44d fix(action): correct action chain fixing error 2026-04-20 22:26:31 +08:00
slhaf d90c514159 fix(communication): fix errors while import external xml node into input xml 2026-04-20 16:40:36 +08:00
slhaf ac715602a6 refactor(gateway): print warn log while receiving invalid input 2026-04-20 15:36:23 +08:00
slhaf e1a5a07e2a Merge branch 'codex-review-localrunnerclient-v2' 2026-04-20 14:48:51 +08:00
slhaf 9108d1db46 feat(gateway): support assign hostname on websocket gateway 2026-04-20 14:38:58 +08:00
slhaf 95a3f782e6 Merge branch 'codex-review-localrunnerclient-v2' 2026-04-20 14:06:03 +08:00
slhaf 6ec3bfcc9b fix(model): correct deserialization behavior of model provider config 2026-04-19 18:52:57 +08:00
slhaf b4d6be849b fix(agent): run system exit after agent launch failed 2026-04-19 18:52:10 +08:00
slhaf a9e41eb4c7 fix(config): repair unexpected exiting in method initAll 2026-04-19 18:51:23 +08:00
slhaf 7ce7461f79 refactor(exception): correct exception report print message 2026-04-19 17:26:42 +08:00
165 changed files with 9443 additions and 1800 deletions
+4 -6
View File
@@ -4,12 +4,7 @@ target/
!**/src/test/**/target/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
.idea/db-forest-config.xml
.idea/markdown.xml
.idea/
*.iws
*.iml
*.ipr
@@ -63,3 +58,6 @@ build/
/Partner-Core/data/
/.ai/mcp/mcp.json
/.codex
# Maven / build outputs
dependency-reduced-pom.xml
+3
View File
@@ -1,7 +1,10 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>Onebot</w>
<w>onebot</w>
<w>openai</w>
<w>partnerctl</w>
<w>zuper</w>
</words>
</dictionary>
+8 -5
View File
@@ -1,17 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/Partner-Common/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Common/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Core/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Core/src/main/java/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Core/src/main/java/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Core/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-External-Modules/Partner-Onebot-Adapter/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-External-Modules/Partner-Onebot-Adapter/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-External-Modules/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-External-Modules/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Framework/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Framework/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-SandboxRunner/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Test-Demo/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Test-Demo/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Interaction-Api/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Interaction-Api/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/PartnerCtl/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/PartnerCtl/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
+22 -18
View File
@@ -1,26 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EntryPointsManager">
<list size="19">
<list size="21">
<item index="0" class="java.lang.String" itemvalue="lombok.Data" />
<item index="1" class="java.lang.String" itemvalue="net.bytebuddy.implementation.bind.annotation.RuntimeType" />
<item index="2" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.Capability" />
<item index="3" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.CapabilityCore" />
<item index="4" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.CapabilityMethod" />
<item index="5" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.CoordinateManager" />
<item index="6" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.Coordinated" />
<item index="7" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.component.annotation.Init" />
<item index="8" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.AfterExecute" />
<item index="9" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.AgentRunningModule" />
<item index="10" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.AgentSubModule" />
<item index="11" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.BeforeExecute" />
<item index="12" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.Init" />
<item index="13" class="java.lang.String" itemvalue="work.slhaf.partner.api.capability.annotation.CapabilityMethod" />
<item index="14" class="java.lang.String" itemvalue="work.slhaf.partner.api.capability.annotation.CoordinateManager" />
<item index="15" class="java.lang.String" itemvalue="work.slhaf.partner.api.register.capability.annotation.Capability" />
<item index="16" class="java.lang.String" itemvalue="work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityCore" />
<item index="17" class="java.lang.String" itemvalue="work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityMethod" />
<item index="18" class="java.lang.String" itemvalue="work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent" />
<item index="2" class="java.lang.String" itemvalue="picocli.CommandLine.Command" />
<item index="3" class="java.lang.String" itemvalue="picocli.CommandLine.Mixin" />
<item index="4" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.Capability" />
<item index="5" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.CapabilityCore" />
<item index="6" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.CapabilityMethod" />
<item index="7" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.CoordinateManager" />
<item index="8" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.Coordinated" />
<item index="9" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.component.annotation.Init" />
<item index="10" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.AfterExecute" />
<item index="11" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.AgentRunningModule" />
<item index="12" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.AgentSubModule" />
<item index="13" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.BeforeExecute" />
<item index="14" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.Init" />
<item index="15" class="java.lang.String" itemvalue="work.slhaf.partner.api.capability.annotation.CapabilityMethod" />
<item index="16" class="java.lang.String" itemvalue="work.slhaf.partner.api.capability.annotation.CoordinateManager" />
<item index="17" class="java.lang.String" itemvalue="work.slhaf.partner.api.register.capability.annotation.Capability" />
<item index="18" class="java.lang.String" itemvalue="work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityCore" />
<item index="19" class="java.lang.String" itemvalue="work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityMethod" />
<item index="20" class="java.lang.String" itemvalue="work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent" />
</list>
<writeAnnotations>
<writeAnnotation name="work.slhaf.partner.api.agent.factory.capability.annotation.InjectCapability" />
@@ -40,6 +42,8 @@
</option>
<option name="ignoredFiles">
<set>
<option value="$PROJECT_DIR$/Partner-Common/pom.xml" />
<option value="$PROJECT_DIR$/Partner-SandboxRunner/pom.xml" />
<option value="$PROJECT_DIR$/Partner-Test-Demo/pom.xml" />
</set>
</option>
-28
View File
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>work.slhaf</groupId>
<artifactId>Partner</artifactId>
<version>0.5.0</version>
</parent>
<artifactId>Partner-Common</artifactId>
<dependencies>
<!-- https://mvnrepository.com/artifact/io.modelcontextprotocol.sdk/mcp -->
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>0.17.0</version>
</dependency>
</dependencies>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
+42 -7
View File
@@ -1,13 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>Partner</artifactId>
<groupId>work.slhaf</groupId>
<artifactId>partner</artifactId>
<groupId>work.slhaf.partner</groupId>
<version>0.5.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>Partner-Main</artifactId>
<artifactId>partner-core</artifactId>
<build>
<plugins>
<plugin>
@@ -29,13 +28,49 @@
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>process-sources</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<skipTests>true</skipTests>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
<configuration>
<jvmTarget>${maven.compiler.target}</jvmTarget>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test</artifactId>
<version>2.2.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<properties>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.source>21</maven.compiler.source>
+5 -11
View File
@@ -4,12 +4,12 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>work.slhaf</groupId>
<artifactId>Partner</artifactId>
<groupId>work.slhaf.partner</groupId>
<artifactId>partner</artifactId>
<version>0.5.0</version>
</parent>
<artifactId>Partner-Core</artifactId>
<artifactId>partner-core</artifactId>
<dependencies>
<dependency>
@@ -18,8 +18,8 @@
<version>1.6.0</version>
</dependency>
<dependency>
<groupId>work.slhaf</groupId>
<artifactId>Partner-Framework</artifactId>
<groupId>work.slhaf.partner</groupId>
<artifactId>partner-framework</artifactId>
<version>0.5.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.nd4j/nd4j-api -->
@@ -44,12 +44,6 @@
<artifactId>mcp</artifactId>
<version>0.17.0</version>
</dependency>
<dependency>
<groupId>work.slhaf</groupId>
<artifactId>Partner-Common</artifactId>
<version>0.5.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
@@ -1,14 +1,12 @@
package work.slhaf.partner;
import work.slhaf.partner.common.vector.VectorClientRegistry;
import work.slhaf.partner.framework.agent.Agent;
import work.slhaf.partner.runtime.gateway.WebSocketGatewayRegistration;
public class Main {
public static void main(String[] args) {
Agent.newAgent(Main.class)
.addGatewayRegistration(WebSocketGatewayRegistration.INSTANCE)
.addConfigurable(new VectorClientRegistry())
.launch();
boolean launched = Agent.newAgent(Main.class).launch();
if (!launched) {
System.exit(1);
}
}
}
@@ -229,13 +229,20 @@ public class ActionCore implements StateSerializable {
}
/**
* 在未进入执行阶段的行动单元组新增新的行动
* 在指定阶段之后追加新的行动单元组
*/
private void handleAppend(ExecutableAction executableAction, int order, List<MetaAction> actions) {
if (order <= executableAction.getExecutingStage())
return;
Map<Integer, List<MetaAction>> actionChain = executableAction.getActionChain();
int targetOrder = nextAvailableOrder(actionChain, order + 1);
actionChain.put(targetOrder, actions);
}
executableAction.getActionChain().put(order, actions);
private int nextAvailableOrder(Map<Integer, List<MetaAction>> actionChain, int startOrder) {
int order = startOrder;
while (actionChain.containsKey(order)) {
order++;
}
return order;
}
/**
@@ -76,6 +76,10 @@ final class ActionPoolStateCodec {
.<StateValue>map(entry -> {
Map<String, StateValue> stageMap = new LinkedHashMap<>();
stageMap.put("stage", StateValue.num(entry.getKey()));
String stageDescription = action.getStageDescriptions().get(entry.getKey());
if (stageDescription != null && !stageDescription.isBlank()) {
stageMap.put("description", StateValue.str(stageDescription));
}
stageMap.put("actions", StateValue.arr(entry.getValue().stream()
.map(metaAction -> (StateValue) encodeMetaAction(metaAction))
.toList()));
@@ -126,7 +130,11 @@ final class ActionPoolStateCodec {
return null;
}
Map<Integer, List<MetaAction>> restoredChain = decodeActionChain(actionObject.getJSONArray("action_chain"));
Map<Integer, String> restoredStageDescriptions = new LinkedHashMap<>();
Map<Integer, List<MetaAction>> restoredChain = decodeActionChain(
actionObject.getJSONArray("action_chain"),
restoredStageDescriptions
);
ExecutableAction executableAction;
if ("schedulable".equals(kind)) {
String scheduleType = actionObject.getString("schedule_type");
@@ -173,11 +181,15 @@ final class ActionPoolStateCodec {
if (result != null) {
executableAction.setResult(result);
}
executableAction.getStageDescriptions().putAll(restoredStageDescriptions);
executableAction.getHistory().putAll(decodeHistory(actionObject.getJSONArray("history")));
return executableAction;
}
private static Map<Integer, List<MetaAction>> decodeActionChain(@Nullable JSONArray actionChainArray) {
private static Map<Integer, List<MetaAction>> decodeActionChain(
@Nullable JSONArray actionChainArray,
Map<Integer, String> stageDescriptions
) {
Map<Integer, List<MetaAction>> restored = new LinkedHashMap<>();
if (actionChainArray == null) {
return toMutableActionChain(restored);
@@ -188,10 +200,14 @@ final class ActionPoolStateCodec {
continue;
}
Integer stage = stageObject.getInteger("stage");
String description = stageObject.getString("description");
JSONArray actions = stageObject.getJSONArray("actions");
if (stage == null || actions == null) {
continue;
}
if (description != null && !description.isBlank()) {
stageDescriptions.put(stage, description);
}
List<MetaAction> metaActions = new ArrayList<>();
for (int j = 0; j < actions.size(); j++) {
JSONObject actionObject = actions.getJSONObject(j);
@@ -96,6 +96,11 @@ sealed class ExecutableAction(
*/
abstract val actionChain: MutableMap<Int, MutableList<MetaAction>>
/**
* 行动阶段描述,用于为每个 stage 提供可落地的执行目标说明
*/
val stageDescriptions: MutableMap<Int, String> = mutableMapOf()
/**
* 行动阶段(当前阶段)
*/
@@ -143,6 +148,7 @@ sealed class ExecutableAction(
status = status,
tendency = tendency,
actionChainSize = actionChain.size,
stageDescriptions = stageDescriptions.toMap(),
executingStage = executingStage,
result = result,
history = history.mapValues { (_, value) -> value.toList() },
@@ -268,6 +274,7 @@ data class ExecutableActionSnapshot(
val tendency: String,
val actionChainSize: Int,
val stageDescriptions: Map<Int, String>,
val executingStage: Int,
val result: String?,
val history: Map<Int, List<HistoryAction>>,
@@ -11,7 +11,6 @@ import io.modelcontextprotocol.spec.McpSchema;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import work.slhaf.partner.common.mcp.InProcessMcpTransport;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.action.exception.ActionInfrastructureStartupException;
import work.slhaf.partner.core.action.runner.execution.CommandExecutionService;
@@ -1,4 +1,4 @@
package work.slhaf.partner.common.mcp;
package work.slhaf.partner.core.action.runner.mcp;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonMapper;
@@ -12,7 +12,6 @@ import javassist.NotFoundException;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import reactor.core.publisher.Mono;
import work.slhaf.partner.common.mcp.InProcessMcpTransport;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import java.io.File;
@@ -1,7 +1,5 @@
package work.slhaf.partner.core.action.runner.mcp;
import work.slhaf.partner.common.mcp.InProcessMcpTransport;
import java.util.List;
import java.util.Map;
@@ -127,7 +127,7 @@ public class CognitionCore implements StateSerializable {
new BlockContent("recent_chat_messages", "communication_producer") {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
document.appendChild(document.importNode(messageNotesElement(), true));
root.appendChild(document.importNode(messageNotesElement(), true));
Element chatMessagesElement = document.createElement("chat_messages");
root.appendChild(chatMessagesElement);
appendRepeatedElements(document, chatMessagesElement, "chat_message", resolveRecentChatMessages(), (messageElement, message) -> {
@@ -4,10 +4,8 @@ import com.alibaba.fastjson2.JSONObject
import org.w3c.dom.Document
import org.w3c.dom.Element
import work.slhaf.partner.common.base.Block
import work.slhaf.partner.framework.agent.config.ConfigCenter
import work.slhaf.partner.framework.agent.log.TraceEvent
import work.slhaf.partner.framework.agent.log.TraceRecorder
import java.nio.file.Path
import java.time.Duration
import java.time.Instant
import java.util.*
@@ -18,11 +16,6 @@ import kotlin.math.min
class ContextWorkspace {
private val tracePath: Path = ConfigCenter.paths.stateDir
.resolve("trace")
.resolve("context-workspace")
.normalize()
.toAbsolutePath()
private val stateSet = mutableSetOf<ContextBlock>()
private val lock = ReentrantReadWriteLock()
@@ -103,7 +96,7 @@ class ContextWorkspace {
* @param contextBlock 注册的新上下文块
*/
fun register(contextBlock: ContextBlock) = lock.write {
val removedBlocks = mutableListOf<ContextBlock>()
val changedSourceKeys = linkedSetOf<ContextBlock.SourceKey>()
val iterator = stateSet.iterator()
while (iterator.hasNext()) {
val currentBlock = iterator.next()
@@ -113,44 +106,51 @@ class ContextWorkspace {
if (currentBlock.applyReplaceFade() <= 0.0) {
iterator.remove()
removedBlocks.add(currentBlock)
changedSourceKeys.add(currentBlock.sourceKey)
}
}
stateSet += contextBlock
recordRegister(contextBlock, removedBlocks)
changedSourceKeys.add(contextBlock.sourceKey)
record("register", changedSourceKeys)
}
fun expire(blockName: String, source: String) = lock.write {
val sourceKey = ContextBlock.SourceKey(blockName, source)
val removedBlocks = mutableListOf<ContextBlock>()
val changedSourceKeys = linkedSetOf<ContextBlock.SourceKey>()
val iterator = stateSet.iterator()
while (iterator.hasNext()) {
val block = iterator.next()
if (block.sourceKey == sourceKey) {
iterator.remove()
removedBlocks.add(block)
changedSourceKeys.add(block.sourceKey)
}
}
if (removedBlocks.isNotEmpty()) {
recordExpire(sourceKey, removedBlocks)
if (changedSourceKeys.isNotEmpty()) {
record("expire", changedSourceKeys)
}
}
private fun recordRegister(addedBlock: ContextBlock, removedBlocks: List<ContextBlock>) {
private fun record(action: String, changedSourceKeys: Set<ContextBlock.SourceKey>) {
val payload = JSONObject()
payload["action"] = "register"
payload["added"] = blockSnapshot(addedBlock)
payload["removed"] = removedBlocks.map(::blockSnapshot)
TraceRecorder.record(TraceEvent(tracePath, payload))
payload["action"] = action
payload["changedSourceKeys"] = changedSourceKeys
.sortedWith(
compareBy<ContextBlock.SourceKey> { it.blockName }
.thenBy { it.source }
)
.map { sourceKey ->
JSONObject.of(
"blockName", sourceKey.blockName,
"source", sourceKey.source
)
}
private fun recordExpire(sourceKey: ContextBlock.SourceKey, removedBlocks: List<ContextBlock>) {
val payload = JSONObject()
payload["action"] = "expire"
payload["blockName"] = sourceKey.blockName
payload["source"] = sourceKey.source
payload["removed"] = removedBlocks.map(::blockSnapshot)
TraceRecorder.record(TraceEvent(tracePath, payload))
payload["blocks"] = stateSet
.sortedWith(
compareBy<ContextBlock> { it.sourceKey.blockName }
.thenBy { it.sourceKey.source }
)
.map(::blockSnapshot)
TraceRecorder.record(TraceEvent("context-workspace", payload))
}
private fun blockSnapshot(block: ContextBlock): JSONObject {
@@ -176,7 +176,7 @@ private data class ResolvedContextBlock(
data class ContextBlock @JvmOverloads constructor(
val blockContent: BlockContent,
val compactBlock: BlockContent = blockContent,
val abstractBlock: BlockContent = blockContent,
val abstractBlock: BlockContent = compactBlock,
/**
* 该 block 集中在哪些域
*/
@@ -1,14 +0,0 @@
package work.slhaf.partner.module
import work.slhaf.partner.common.base.Block
import work.slhaf.partner.framework.agent.model.pojo.Message
abstract class TaskBlock @JvmOverloads constructor(
blockName: String = "task_input"
) : Block(blockName) {
fun encodeToMessage(): Message {
return Message(Message.Character.USER, encodeToXmlString())
}
}
@@ -2,8 +2,13 @@ package work.slhaf.partner.module.action.builtin;
import java.util.List;
interface BuiltinActionProvider {
public interface BuiltinActionProvider {
List<BuiltinActionRegistry.BuiltinActionDefinition> provideBuiltinActions();
String createActionKey(String actionName);
default String param(String requirement, String type, String detail) {
return requirement + "|" + type + "|" + detail;
}
}
@@ -9,7 +9,6 @@ import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCa
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -26,32 +25,26 @@ public class BuiltinActionRegistry extends AbstractAgentModule.Standalone {
@Init
public void init() {
definitions.clear();
for (BuiltinActionDefinition definition : buildDefaultActionDefinitions()) {
definitions.put(definition.actionKey(), definition);
}
actionCapability.registerMetaActions(exportMetaActionInfos());
actionCapability.runnerClient().setBuiltinActionRegistry(this);
}
protected List<BuiltinActionDefinition> buildDefaultActionDefinitions() {
List<BuiltinActionDefinition> builtinActionDefinitions = new ArrayList<>();
BuiltinActionProvider commandActionProvider = new BuiltinCommandActionProvider();
BuiltinActionProvider capabilityActionProvider = new BuiltinCapabilityActionProvider();
BuiltinActionProvider interventionActionProvider = new BuiltinInterventionActionProvider();
BuiltinActionProvider dynamicActionProvider = new BuiltinDynamicActionProvider();
builtinActionDefinitions.addAll(commandActionProvider.provideBuiltinActions());
builtinActionDefinitions.addAll(capabilityActionProvider.provideBuiltinActions());
builtinActionDefinitions.addAll(interventionActionProvider.provideBuiltinActions());
builtinActionDefinitions.addAll(dynamicActionProvider.provideBuiltinActions());
return builtinActionDefinitions;
public void register(BuiltinActionProvider provider) {
List<BuiltinActionDefinition> builtinActionDefinitions = provider.provideBuiltinActions();
if (builtinActionDefinitions == null || builtinActionDefinitions.isEmpty()) {
return;
}
Map<String, MetaActionInfo> metaActionInfos = new LinkedHashMap<>();
for (BuiltinActionDefinition definition : builtinActionDefinitions) {
definitions.put(definition.actionKey(), definition);
metaActionInfos.put(definition.actionKey(), definition.metaActionInfo());
}
actionCapability.registerMetaActions(metaActionInfos);
}
public void defineBuiltinAction(String name, MetaActionInfo metaActionInfo, Function<Map<String, Object>, String> invoker) {
BuiltinActionDefinition definition = new BuiltinActionDefinition(BUILTIN_LOCATION + "::" + name, metaActionInfo, invoker);
definitions.put(definition.actionKey(), definition);
actionCapability.registerMetaActions(Map.of(definition.actionKey(), definition.metaActionInfo()));
}
public String call(@NonNull String actionKey, @NonNull Map<String, Object> params) {
@@ -70,11 +63,6 @@ public class BuiltinActionRegistry extends AbstractAgentModule.Standalone {
return result;
}
private Map<String, MetaActionInfo> exportMetaActionInfos() {
Map<String, MetaActionInfo> metaActions = new LinkedHashMap<>();
definitions.forEach((key, value) -> metaActions.put(key, value.metaActionInfo()));
return metaActions;
}
public record BuiltinActionDefinition(
String actionKey,
@@ -14,6 +14,8 @@ import work.slhaf.partner.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemoryUnit;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule;
import work.slhaf.partner.framework.agent.support.Result;
import java.util.*;
@@ -32,6 +34,13 @@ class BuiltinCapabilityActionProvider implements BuiltinActionProvider {
private CognitionCapability cognitionCapability;
@InjectCapability
private MemoryCapability memoryCapability;
@InjectModule
private BuiltinActionRegistry builtinActionRegistry;
@Init
public void init() {
builtinActionRegistry.register(this);
}
@Override
public List<BuiltinActionRegistry.BuiltinActionDefinition> provideBuiltinActions() {
@@ -49,11 +58,10 @@ class BuiltinCapabilityActionProvider implements BuiltinActionProvider {
false,
null,
Map.of(
"unit_id", "The id of the memory unit that contains the target memory slice.",
"slice_id", "The id of the memory slice to recall into context."
"unit_id", param("required", "string", "Memory unit id that contains the target memory slice."),
"slice_id", param("required", "string", "Memory slice id to load into short-lived context.")
),
"Recall the target memory slice into context using its unit_id and slice_id. " +
"This action loads the slice's original conversation messages as a short-lived recalled memory context block.",
"Purpose: bring a known memory slice back into the agent's working context so later reasoning can use the original conversation messages. Use when: the exact memory unit and slice have already been identified. Input: memory slice id and memory unit id. Does not: search, rank, summarize, or infer memories. Returns: whether the slice was recalled and a short result message.",
tags,
Set.of(),
Set.of(),
@@ -125,10 +133,10 @@ class BuiltinCapabilityActionProvider implements BuiltinActionProvider {
false,
null,
Map.of(
"input", "Input required to initiate an internal Agent Turn.",
"target", "The people expected to reply to this internal Agent Turn."
"input", param("required", "string", "Prompt content for the internal Agent Turn."),
"target", param("required", "string", "Target user expected to receive/respond to the turn.")
),
"Create an internal Agent Turn to resolve a task.",
"Purpose: initiate a self-dialogue or proactive dialogue by sending input to a target participant. Use when: the agent needs to start a new turn for reflection, clarification, coordination, or active communication. Input: input content and the user expected to respond. Returns: confirmation that the turn was initiated. Does not: wait for or include the target's answer.",
tags,
Set.of(),
Set.of(),
@@ -6,21 +6,25 @@ import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.action.runner.execution.CommandExecutionService;
import work.slhaf.partner.core.action.runner.policy.ExecutionPolicyRegistry;
import work.slhaf.partner.core.action.runner.policy.WrappedLaunchSpec;
import work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule;
import java.time.Instant;
import java.time.Duration;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import static work.slhaf.partner.core.action.ActionCore.BUILTIN_LOCATION;
@AgentComponent
class BuiltinCommandActionProvider implements BuiltinActionProvider {
private static final String COMMAND_LOCATION = BUILTIN_LOCATION + "::" + "command";
@@ -38,6 +42,30 @@ class BuiltinCommandActionProvider implements BuiltinActionProvider {
private final ConcurrentHashMap<String, CommandHandle> commandHandles = new ConcurrentHashMap<>();
private final CommandExecutionService commandExecutionService = CommandExecutionService.INSTANCE;
@InjectModule
private BuiltinActionRegistry builtinActionRegistry;
@Init
public void init() {
builtinActionRegistry.register(this);
}
private Map<String, String> commandParams() {
Map<String, String> params = new LinkedHashMap<>();
params.put("arg", param("required", "string", "Command executable name or path."));
params.put("arg1", param("optional", "string", "First command argument after the executable."));
params.put("arg2", param("optional", "string", "Second command argument after the executable."));
params.put("argN", param("optional", "string", "Additional command arguments after arg2. Use consecutive numeric keys such as arg3, arg4, arg5, ... when more arguments are needed."));
return params;
}
private Map<String, String> commandSessionParams() {
Map<String, String> params = new LinkedHashMap<>();
params.put("desc", param("required", "string", "Short human-readable description of why this background command session exists."));
params.putAll(commandParams());
return params;
}
@Override
public List<BuiltinActionRegistry.BuiltinActionDefinition> provideBuiltinActions() {
return List.of(
@@ -61,8 +89,8 @@ class BuiltinCommandActionProvider implements BuiltinActionProvider {
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of("arg / argN", "Command arguments. Use arg for first argument, arg1/arg2... for remaining arguments."),
"Execute any allowed system commands and get result instantly, the number of arguments is not limited.",
commandParams(),
"Purpose: execute a short, non-interactive system command synchronously and return its completed output. Inputs: argv entries through arg/argN. Returns: JSON with result containing stdout/stderr/exit summary text. Use when: the task can finish quickly in one process call. Notes: do not use for long-running services, interactive programs, streaming output, or processes that need later inspection.",
tags,
Set.of(),
Set.of(createActionKey("inspect")),
@@ -92,11 +120,8 @@ class BuiltinCommandActionProvider implements BuiltinActionProvider {
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of(
"desc", "Command session description.",
"arg / argN", "Command arguments. Use arg for first argument, arg1/arg2... for remaining arguments."
),
"Start a background command session and return execution id.",
commandSessionParams(),
"Purpose: start a long-running or streaming command as a background session. Inputs: session description and argument entries. Returns: JSON with executionId; pass it as id to inspect, read, or cancel. Use when: the command may run for a while, produce incremental output, or need later control. Notes: usually follow with builtin::command::inspect or builtin::command::read.",
tags,
Set.of(),
Set.of(createActionKey("inspect")),
@@ -145,8 +170,8 @@ class BuiltinCommandActionProvider implements BuiltinActionProvider {
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of("id", "Command session id."),
"Inspect a background command session.",
Map.of("id", param("required", "string", "Command executionId returned by builtin::command::start.")),
"Purpose: inspect status and short output summaries for an existing background command session. Inputs: id from command::start. Returns: JSON with executionId, desc, exitCode, stdoutSize, stderrSize, stdoutSummary, stderrSummary, startAt, and endAt. Use when: deciding whether a background command is still running, failed, or needs a full output read. Notes: use builtin::command::read for complete or paginated output.",
tags,
Set.of(createActionKey("overview")),
Set.of(),
@@ -195,12 +220,12 @@ class BuiltinCommandActionProvider implements BuiltinActionProvider {
false,
null,
Map.of(
"id", "Command execution session id.",
"stream", "Target stream, stdout or stderr. Default stdout.",
"offset", "Read start offset. Default 0.",
"limit", "Read max length. Default 4096."
"id", param("required", "string", "Command executionId returned by builtin::command::start."),
"stream", param("optional", "string", "Target stream to read. Allowed values: stdout, stderr. Default: stdout."),
"offset", param("optional", "int", "Start byte offset in the persisted stream log. Default: 0. Use nextOffset from a previous read to continue."),
"limit", param("optional", "int", "Maximum bytes to read from the persisted stream log. Default: 4096. Must be greater than 0.")
),
"Read output from a background command session.",
"Purpose: read stdout or stderr content from a background command session with offset pagination. Returns: JSON with content, contentTruncated, nextOffset, and eof. Use when: inspect summaries are insufficient or a command failed and stderr/stdout details are needed. Notes: repeat with nextOffset until eof or enough evidence is collected.",
tags,
Set.of(createActionKey("overview")),
Set.of(),
@@ -263,8 +288,8 @@ class BuiltinCommandActionProvider implements BuiltinActionProvider {
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of("id", "Command session id."),
"Cancel a background command session.",
Map.of("id", param("required", "string", "Command executionId returned by builtin::command::start.")),
"Purpose: terminate an existing background command session. Inputs: id from command::start. Returns: JSON with ok and executionId. Use when: the user explicitly asks to stop a command, or correction determines the running command is wrong, stuck, unsafe, or no longer needed. Notes: this has process side effects and should not be used as a status check.",
tags,
Set.of(),
Set.of(createActionKey("overview")),
@@ -316,16 +341,16 @@ class BuiltinCommandActionProvider implements BuiltinActionProvider {
false,
null,
Map.of(),
"List all background command sessions.",
"Purpose: list known background command sessions. Inputs: none. Returns: JSON with result array of session overviews containing executionId, desc, and exitCode. Use when: an id is missing or correction needs to discover active command sessions before inspect/read/cancel. Notes: this does not start, stop, or read a command.",
tags,
Set.of(createActionKey("start")),
Set.of(createActionKey("inspect"), createActionKey("read"), createActionKey("cancel")),
false,
JSONObject.of(
"commands", "Array of command session overview items.",
"command.executionId", "Command execution session id for each overview item.",
"command.desc", "Command session description for each overview item.",
"command.exitCode", "Process exit code for each overview item. Null when still running."
"result", "Array of command session overview items.",
"result[].executionId", "Command execution session id for each overview item.",
"result[].desc", "Command session description for each overview item.",
"result[].exitCode", "Process exit code for each overview item. Null when still running."
)
);
Function<Map<String, Object>, String> invoker = params -> {
@@ -492,30 +517,36 @@ class BuiltinCommandActionProvider implements BuiltinActionProvider {
}
private List<String> requireCommandArguments(Map<String, Object> params) {
List<Map.Entry<String, Object>> entries = params.entrySet().stream()
List<Map.Entry<String, Object>> unexpectedArgs = params.entrySet().stream()
.filter(entry -> entry.getKey().startsWith(COMMAND_ARG_PREFIX))
.sorted(Comparator.comparingInt(entry -> commandArgIndex(entry.getKey())))
.filter(entry -> !COMMAND_ARG_PREFIX.equals(entry.getKey()) && !isNumberedCommandArg(entry.getKey()))
.toList();
if (entries.isEmpty()) {
throw new IllegalArgumentException("缺少命令参数");
if (!unexpectedArgs.isEmpty()) {
throw new IllegalArgumentException("非法命令参数: " + unexpectedArgs.getFirst().getKey());
}
List<String> commands = new ArrayList<>();
for (Map.Entry<String, Object> entry : entries) {
commands.add(BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, entry.getKey()));
}
commands.add(BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, COMMAND_ARG_PREFIX));
params.entrySet().stream()
.filter(entry -> isNumberedCommandArg(entry.getKey()))
.sorted(Comparator.comparingInt(entry -> commandArgIndex(entry.getKey())))
.forEach(entry -> commands.add(BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, entry.getKey())));
return commands;
}
private int commandArgIndex(String key) {
private boolean isNumberedCommandArg(String key) {
if (!key.startsWith(COMMAND_ARG_PREFIX) || COMMAND_ARG_PREFIX.equals(key)) {
return false;
}
String suffix = key.substring(COMMAND_ARG_PREFIX.length()).trim();
if (suffix.isEmpty()) {
return 0;
return false;
}
try {
return Integer.parseInt(suffix);
} catch (NumberFormatException ignored) {
return Integer.MAX_VALUE;
return suffix.chars().allMatch(Character::isDigit);
}
private int commandArgIndex(String key) {
return Integer.parseInt(key.substring(COMMAND_ARG_PREFIX.length()).trim());
}
private String bufferSnapshot(StringBuilder buffer) {
@@ -7,6 +7,7 @@ import work.slhaf.partner.core.action.ActionCapability;
import work.slhaf.partner.core.action.entity.*;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule;
import work.slhaf.partner.module.action.scheduler.ActionScheduler;
@@ -29,6 +30,13 @@ class BuiltinDynamicActionProvider implements BuiltinActionProvider {
private ActionCapability actionCapability;
@InjectModule
private ActionScheduler actionScheduler;
@InjectModule
private BuiltinActionRegistry builtinActionRegistry;
@Init
public void init() {
builtinActionRegistry.register(this);
}
@Override
public List<BuiltinActionRegistry.BuiltinActionDefinition> provideBuiltinActions() {
@@ -43,13 +51,13 @@ class BuiltinDynamicActionProvider implements BuiltinActionProvider {
false,
null,
Map.of(
"desc", "Human-readable description for the temporary action. Required because the generated action name is only a short id.",
"code", "Dynamic action source code content.",
"codeType", "Code extension, for example py/sh/js.",
"launcher", "Interpreter or launcher used for ORIGIN execution.",
"meta", "MetaActionInfo extra fields as JSON string. Available fields example: {\"io\":true,\"params\":{\"input\":\"user input\"},\"tags\":[\"dynamic\"],\"preActions\":[\"builtin::command::execute\"],\"postActions\":[\"builtin::dynamic::persist\"],\"strictDependencies\":false,\"responseSchema\":{\"result\":\"dynamic result\"}}"
"desc", param("required", "string", "Human-readable description for the generated temporary action. This becomes the new action's planning description."),
"code", param("required", "string", "Dynamic action source code content."),
"codeType", param("required", "string", "Source file extension without or with leading dot, for example py, sh, js, or .py."),
"launcher", param("required", "string", "Interpreter or launcher command used to run the generated code, for example python3, bash, or node."),
"meta", param("required", "json-string", "MetaActionInfo fields for the generated action. Supported fields: io, params, tags, preActions, postActions, strictDependencies, responseSchema.")
),
"Generate a temporary ORIGIN action from source code and return a temporary actionKey.",
"Purpose: generate and register a temporary script as an ORIGIN MetaAction from supplied source code. Inputs: desc, code, codeType, launcher, and meta info. Returns: JSON with ok and temporary actionKey. Use when: existing MetaActions cannot perform the task and a temporary code-backed action is needed. Notes: this registers executable code with cleanup TTL; the generated action is temporary until builtin::dynamic::persist is called.",
basicTags,
Set.of(),
Set.of(createActionKey("persist")),
@@ -110,8 +118,8 @@ class BuiltinDynamicActionProvider implements BuiltinActionProvider {
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of("actionKey", "Temporary ORIGIN actionKey returned by generate."),
"Persist a temporary ORIGIN action and cancel its cleanup task.",
Map.of("actionKey", param("required", "string", "Temporary ORIGIN actionKey returned by builtin::dynamic::generate.")),
"Purpose: persist a previously generated temporary ORIGIN MetaAction and cancel its cleanup task. Inputs: actionKey from dynamic::generate. Returns: JSON with ok and actionKey. Use when: a generated temporary action should become reusable beyond its cleanup TTL. Notes: this has persistence side effects and should only be used when the generated action is validated or explicitly requested.",
basicTags,
Set.of(createActionKey("generate")),
Set.of(),
@@ -17,6 +17,8 @@ import work.slhaf.partner.framework.agent.exception.AgentRuntimeException;
import work.slhaf.partner.framework.agent.exception.ExceptionReporterHandler;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule;
import work.slhaf.partner.framework.agent.support.Result;
import java.util.*;
@@ -35,6 +37,13 @@ class BuiltinInterventionActionProvider implements BuiltinActionProvider {
private ActionCapability actionCapability;
@InjectCapability
private CognitionCapability cognitionCapability;
@InjectModule
private BuiltinActionRegistry builtinActionRegistry;
@Init
public void init() {
builtinActionRegistry.register(this);
}
@Override
public List<BuiltinActionRegistry.BuiltinActionDefinition> provideBuiltinActions() {
@@ -56,9 +65,9 @@ class BuiltinInterventionActionProvider implements BuiltinActionProvider {
false,
null,
Map.of(
"actionId", "Uuid of the interrupted executable action to resume."
"actionId", param("required", "string", "Uuid of an executable action currently in INTERRUPTED status.")
),
"Resume action that is interrupted.",
"Purpose: resume an executable action that is currently interrupted. Inputs: actionId. Returns: plain text success or failure reason. Use when: an intervention or user decision is complete and the original action should continue. Notes: this does not modify the action chain; use create_intervention before resuming when chain changes are required.",
tags,
Set.of(),
Set.of(),
@@ -71,7 +80,7 @@ class BuiltinInterventionActionProvider implements BuiltinActionProvider {
Function<Map<String, Object>, String> invoker = params -> {
String actionId = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "actionId");
try {
ExecutableAction executableAction = getExecutableAction(actionId);
ExecutableAction executableAction = getExecutableAction(actionId, Action.Status.INTERRUPTED);
executableAction.resume();
return "Resume succeed";
} catch (Exception e) {
@@ -101,14 +110,14 @@ class BuiltinInterventionActionProvider implements BuiltinActionProvider {
true,
null,
Map.of(
"actionId", "Uuid of the executing action that should enter intervention flow.",
"actionInfo", "Readable summary of the current action, used to explain the intervention context to the target.",
"demand", "What feedback, decision or operation is required from the target user.",
"target", "Target user or channel identifier that should receive the intervention request.",
"input", "Prompt content used to initiate the intervention turn toward the target.",
"timeout", "Maximum wait time for the interruption result, in the unit expected by ExecutableAction.interrupt(timeout)."
"actionId", param("required", "string", "Uuid of the executing action that should be interrupted for external intervention."),
"actionInfo", param("required", "string", "Readable summary of the current action and why intervention is needed."),
"demand", param("required", "string", "Specific feedback, decision, permission, or operation required from the target."),
"target", param("required", "string", "Target user or channel identifier that should receive the intervention request."),
"input", param("required", "string", "Prompt content used to initiate the intervention turn toward the target."),
"timeout", param("required", "int", "Maximum wait time passed to ExecutableAction.interrupt(timeout).")
),
"Try to acquire the target user to intervene this Action.",
"Purpose: interrupt an executing action and ask a target participant/channel for intervention. Inputs: actionId, actionInfo, demand, target, input, timeout. Returns: plain text intervention status. Use when: execution cannot safely continue without user decision, missing information, permission, or manual operation. Notes: this creates communication side effects and temporarily interrupts the action.",
tags,
Set.of(),
Set.of(),
@@ -147,7 +156,7 @@ class BuiltinInterventionActionProvider implements BuiltinActionProvider {
ExecutableAction executableAction = null;
try {
executableAction = getExecutableAction(actionId);
executableAction = getExecutableAction(actionId, Action.Status.EXECUTING);
cognitionCapability.initiateTurn(input, target);
boolean normal = executableAction.interrupt(timeout);
return normal ? target + "not resumed execution in time" : target + "answered, looking for related answer in recent-chat-messages";
@@ -168,12 +177,12 @@ class BuiltinInterventionActionProvider implements BuiltinActionProvider {
);
}
private ExecutableAction getExecutableAction(String actionId) {
return actionCapability.listActions(Action.Status.EXECUTING, null)
private ExecutableAction getExecutableAction(String actionId, Action.Status status) {
return actionCapability.listActions(status, null)
.stream()
.filter(action -> action.getUuid().equals(actionId))
.findFirst()
.orElseThrow();
.orElseThrow(() -> new IllegalArgumentException("未找到对应状态的 Action: " + actionId + ", status=" + status));
}
/**
@@ -190,7 +199,7 @@ class BuiltinInterventionActionProvider implements BuiltinActionProvider {
false,
null,
Map.of(),
"List existing actions that can be intervened",
"Purpose: list executable actions that can be inspected for possible intervention. Inputs: none. Returns: JSON array with tendency, description, status, and uuid for each action. Use when: an action id is needed before acquire_intervention, create_intervention, or resume_interrupted_action. Notes: this only discovers actions; it does not modify them.",
tags,
Set.of(),
Set.of(),
@@ -237,7 +246,7 @@ class BuiltinInterventionActionProvider implements BuiltinActionProvider {
false,
null,
Map.of(),
"List available MetaActions.",
"Purpose: list currently available MetaActions that can be used in action chains or interventions. Inputs: none. Returns: JSON array with action key/location, description, tags, and params. Use when: correction needs to discover valid action_key values for APPEND, INSERT, DELETE, or REBUILD. Notes: this is a discovery action and does not execute those MetaActions.",
tags,
Set.of(),
Set.of(),
@@ -282,12 +291,12 @@ class BuiltinInterventionActionProvider implements BuiltinActionProvider {
false,
null,
Map.of(
"id", "The uuid of the Action to be intervened on.",
"type", "Intervention type. Allowed values: APPEND, INSERT, DELETE, CANCEL.",
"order", "Action chain order/stage to apply the intervention on.",
"actions", "Comma-separated actionKey list to be inserted, appended, rebuilt or deleted. Example: \"builtin::command::execute, builtin::capability::show_memory_slices\""
"id", param("required", "string", "Uuid of the executable action to modify."),
"type", param("required", "string", "Intervention type. Allowed values: APPEND, INSERT, DELETE, CANCEL, REBUILD."),
"order", param("required", "int", "Action chain order/stage where the intervention applies."),
"actions", param("conditional", "string", "Comma-separated actionKey list to insert, append, rebuild, or delete. Required except for CANCEL. Example: builtin::command::execute,builtin::command::read.")
),
"Used to create an Action Intervention and act on the specified Executable Action.",
"Purpose: apply a structural intervention to an executable action chain. Inputs: id, type, order, and conditional actions. Returns: JSON with ok and optional result. Use when: correction must append, insert, delete, cancel, or rebuild future action-chain steps. Notes: actions must be valid action_key values; CANCEL may omit actions; this has direct action-chain side effects and resumes the target action after applying changes.",
basicTags,
Set.of(
createActionKey("list_available_meta_actions"),
@@ -324,8 +333,7 @@ class BuiltinInterventionActionProvider implements BuiltinActionProvider {
actionCapability.handleInterventions(List.of(intervention), target);
ExecutableAction executableAction = getExecutableAction(targetId);
executableAction.resume();
target.resume();
return JSONObject.of("ok", true).toJSONString();
};
@@ -28,21 +28,25 @@ public class ActionCorrectionRecognizer extends AbstractAgentModule.Sub<Correcto
你会收到:
- 一条结构化上下文消息,其中包含近期交流轨迹与当前活跃记忆切片;
- 一条任务消息,其中包含:
- check_mode:当前识别模式,PROCESS_CHECK 表示过程纠偏识别,FINAL_CHECK 表示链路末尾完成度验收;
- executable_action_info:当前正在执行的行动信息,包括 executing_action_id、original_tendency、evaluation_passed_reason、description 与 from_who;
- current_action_chain_overview:当前行动链概览,按 stage_count 分组,包含各阶段已有 meta_action 的 action_key、description 与 status。
- current_action_chain_overview:当前行动链概览,按 stage_count 分组,包含各阶段已有 meta_action 的 action_key、description、status 与 result。
你的任务:
- 基于当前上下文、当前行动信息与当前行动链概览,判断这条行动链是否仍在合理推进;
- 判断当前是否已经出现明显偏航、停滞、重复打转、条件失配、目标漂移,或与最新语境不再一致的情况;
- 当 check_mode=PROCESS_CHECK 时,基于当前上下文、当前行动信息与当前行动链概览,判断这条行动链是否仍在合理推进;
- 当 check_mode=FINAL_CHECK 时,判断当前行动链即将结束时,original_tendency 是否已经被执行结果满足;
- 判断当前是否已经出现明显偏航、停滞、重复打转、条件失配、目标漂移、目标未完成,或与最新语境不再一致的情况;
- 若需要引入 corrector,则返回 needCorrection=true,并给出简洁明确的 reason;
- 若当前仍可继续推进,则返回 needCorrection=false。
- 若当前仍可继续推进或已经满足目标,则返回 needCorrection=false。
识别原则:
- executable_action_info 用于说明当前链路最初为何成立、要解决什么问题、当前由谁发起;你的判断必须围绕这条行动本身,而不是被无关历史带偏。
- executing_action_id 用于标识当前正在执行的行动;current_action_chain_overview 用于说明当前链路整体结构与已知阶段状态。
- executing_action_id 用于标识当前正在执行的行动;current_action_chain_overview 用于说明当前链路整体结构、已知阶段状态与执行结果。
- original_tendency 表示这条行动链最初要解决的问题;若当前链路明显偏离这一倾向,应优先视为异常信号。
- evaluation_passed_reason 与 description 表示该行动最初为何被判定为可推进;若当前状态已经与这些前提不再一致,应考虑触发纠正。
- current_action_chain_overview 是判断当前链路是否停滞、重复、缺步骤、顺序异常或整体失衡的主要依据。
- current_action_chain_overview 是判断当前链路是否停滞、重复、缺步骤、顺序异常、整体失衡或最终目标未满足的主要依据。
- PROCESS_CHECK 关注执行过程是否仍然合理;FINAL_CHECK 关注链路结果是否满足 original_tendency。
- FINAL_CHECK 时应重点查看各 meta_action 的 result;如果 result 显示对象不存在、权限不足、参数错误、输出为空、命令失败、目标未产生或结果明显不符合 original_tendency,应触发纠正。
- communication 域用于判断最新交流语境是否已发生明显变化,导致当前行动继续推进不再合适。
- memory 域只在与当前行动明显相关时作为辅助参考使用。
@@ -51,12 +55,14 @@ public class ActionCorrectionRecognizer extends AbstractAgentModule.Sub<Correcto
- 当前执行显著偏离 original_tendency,开始围绕无关目标展开;
- 当前行动所依赖的前提已被新的交流内容或上下文推翻;
- 当前行动链反复遇到同类失败、阻塞或空转迹象,继续按原链推进意义不大;
- 当前行动链已经明显需要改写策略、调整阶段顺序、补入新步骤或删除无效步骤,但现有链路无法自行收敛。
- 当前行动链已经明显需要改写策略、调整阶段顺序、补入新步骤或删除无效步骤,但现有链路无法自行收敛;
- FINAL_CHECK 时,执行结果明确显示 original_tendency 尚未满足,且可能通过补救步骤继续推进。
不应轻易触发纠正的情形:
- 只是正常的多步推进;
- 存在短暂等待、一次性失败或合理重试,但整体方向仍正确;
- PROCESS_CHECK 时,只是正常的多步推进;
- PROCESS_CHECK 时,存在短暂等待、一次性失败或合理重试,但整体方向仍正确;
- 行动链整体仍与 original_tendency、evaluation_passed_reason 和当前语境保持一致;
- FINAL_CHECK 时,执行结果已经足以满足 original_tendency,只需要向用户报告结果;
- 仅凭旧对话、低相关记忆或轻微波动,不足以判定当前行动异常。
关于输出:
@@ -85,6 +91,7 @@ public class ActionCorrectionRecognizer extends AbstractAgentModule.Sub<Correcto
return new TaskBlock() {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "check_mode", input.getCheckMode());
appendChildElement(document, root, "executable_action_info", block -> {
appendTextElement(document, block, "executing_action_id", input.getActionId());
appendTextElement(document, block, "original_tendency", input.getTendency());
@@ -100,6 +107,7 @@ public class ActionCorrectionRecognizer extends AbstractAgentModule.Sub<Correcto
appendTextElement(document, metaActionElement, "action_key", metaActionData.getActionKey());
appendTextElement(document, metaActionElement, "description", metaActionData.getDescription());
appendTextElement(document, metaActionElement, "status", metaActionData.getStatus());
appendTextElement(document, metaActionElement, "result", metaActionData.getResult());
return Unit.INSTANCE;
});
return Unit.INSTANCE;
@@ -111,6 +119,7 @@ public class ActionCorrectionRecognizer extends AbstractAgentModule.Sub<Correcto
private Message resolveContextMessage() {
return cognitionCapability.contextWorkspace().resolve(List.of(
ContextBlock.FocusedDomain.COMMUNICATION,
ContextBlock.FocusedDomain.PERCEIVE,
ContextBlock.FocusedDomain.MEMORY
)).encodeToMessage();
}
@@ -4,6 +4,7 @@ import kotlin.Unit;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import work.slhaf.partner.core.action.ActionCapability;
import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.ContextBlock;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
@@ -28,13 +29,17 @@ public class ActionCorrector extends AbstractAgentModule.Sub<CorrectorInput, Res
你会收到:
- 一条结构化上下文消息,其中包含近期交流轨迹与当前活跃记忆切片;
- 一条任务消息,其中包含:
- check_mode:当前纠偏模式,PROCESS_CHECK 表示过程纠偏,FINAL_CHECK 表示链路末尾目标未满足后的补救;
- executable_action_info:当前正在执行的行动信息,包括 executing_action_id、original_tendency、evaluation_passed_reason、description 与 from_who;
- current_action_chain_overview:当前行动链概览,按 stage_count 分组,包含各阶段已有 meta_action 的 action_key、description 与 status。
- current_action_chain_overview:当前行动链概览,按 stage_count 分组,包含各阶段已有 meta_action 的 action_key、description、stage_description、status 与 result;stage_description 是该阶段的具体执行目标,description 是 MetaAction 的通用能力说明。
- available_meta_action:当前系统真实可用的 MetaAction 候选,包含 meta_action_key 与 meta_action_description。
你的任务:
- 基于当前上下文、原始行动意图与当前行动链进展,判断后续行动是否仍然符合目的;
- 若当前链路仍可继续推进,则不要随意干预;
- 当 check_mode=PROCESS_CHECK 时,基于当前上下文、原始行动意图与当前行动链进展,判断后续行动是否仍然符合目的;
- 当 check_mode=FINAL_CHECK 时,基于当前行动链的最终结果判断 original_tendency 尚未满足时应如何补救;
- 若当前链路仍可继续推进或最终结果已足够满足目标,则不要随意干预;
- 若当前链路已明显跑偏、缺少必要步骤、顺序不合理、存在冗余、已经不再适合继续,或需要引入新的动作单元,则输出干预方案;
- FINAL_CHECK 下若结果显示目标未满足,应优先补入可继续执行的后续动作;如果无法补救,可以输出空 intervention 并在 correctionReason 中说明原因。
- correctionReason 用于简洁说明为何需要这些纠偏。
纠偏原则:
@@ -43,6 +48,7 @@ public class ActionCorrector extends AbstractAgentModule.Sub<CorrectorInput, Res
- original_tendency 是这条行动链最初要解决的问题;后续行动的调整必须仍然围绕它。
- evaluation_passed_reason 与 description 表示该行动最初为何能够成立;若当前链路已经与这些前提不一致,可据此进行纠偏。
- current_action_chain_overview 是判断后续链路是否缺步骤、顺序失衡、重复冗余或整体方向错误的主要依据。
- available_meta_action 是你进行 APPEND、INSERT 或 REBUILD 时可选择的动作全集;不要使用其中不存在的 action_key。
- communication 域用于判断最新交流语境是否已经变化,导致当前链路需要调整。
- memory 域只在与当前行动明显相关时作为辅助参考使用。
@@ -88,6 +94,8 @@ public class ActionCorrector extends AbstractAgentModule.Sub<CorrectorInput, Res
@InjectCapability
private CognitionCapability cognitionCapability;
@InjectCapability
private ActionCapability actionCapability;
@Override
protected @NotNull Result<CorrectorResult> doExecute(CorrectorInput input) {
@@ -102,6 +110,7 @@ public class ActionCorrector extends AbstractAgentModule.Sub<CorrectorInput, Res
return new TaskBlock() {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "check_mode", input.getCheckMode());
appendChildElement(document, root, "executable_action_info", block -> {
appendTextElement(document, block, "executing_action_id", input.getActionId());
appendTextElement(document, block, "original_tendency", input.getTendency());
@@ -116,11 +125,26 @@ public class ActionCorrector extends AbstractAgentModule.Sub<CorrectorInput, Res
appendRepeatedElements(document, stageElement, "meta_action", stageData.getValue(), (metaActionElement, metaActionData) -> {
appendTextElement(document, metaActionElement, "action_key", metaActionData.getActionKey());
appendTextElement(document, metaActionElement, "description", metaActionData.getDescription());
if (metaActionData.getStageDescription() != null && !metaActionData.getStageDescription().isBlank()) {
appendTextElement(document, metaActionElement, "stage_description", metaActionData.getStageDescription());
}
appendTextElement(document, metaActionElement, "status", metaActionData.getStatus());
appendTextElement(document, metaActionElement, "result", metaActionData.getResult());
return Unit.INSTANCE;
});
return Unit.INSTANCE;
});
appendRepeatedElements(
document,
root,
"available_meta_action",
actionCapability.listAvailableMetaActions().entrySet(),
(block, value) -> {
appendTextElement(document, block, "meta_action_key", value.getKey());
appendTextElement(document, block, "meta_action_description", value.getValue().getDescription());
return Unit.INSTANCE;
}
);
}
}.encodeToMessage();
}
@@ -128,6 +152,7 @@ public class ActionCorrector extends AbstractAgentModule.Sub<CorrectorInput, Res
private Message resolveContextMessage() {
return cognitionCapability.contextWorkspace().resolve(List.of(
ContextBlock.FocusedDomain.COMMUNICATION,
ContextBlock.FocusedDomain.PERCEIVE,
ContextBlock.FocusedDomain.MEMORY
)).encodeToMessage();
}
@@ -28,6 +28,7 @@ import java.util.stream.Collectors;
public class ActionExecutor extends AbstractAgentModule.Standalone {
private static final int MAX_EXTRACTOR_ATTEMPTS = 3;
private static final int MAX_FINAL_CORRECTION_ATTEMPTS = 1;
private final AssemblyHelper assemblyHelper = new AssemblyHelper();
@@ -105,12 +106,12 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
if (action instanceof ExecutableAction executableAction) {
if (action.getStatus() == Action.Status.FAILED) {
ensureExecutableResult(executableAction, true, null);
blockManager.emitActionFinishedBlock(executableAction);
emitExecutableActionFinished(executableAction);
return;
}
action.setStatus(Action.Status.SUCCESS);
ensureExecutableResult(executableAction, false, null);
blockManager.emitActionFinishedBlock(executableAction);
emitExecutableActionFinished(executableAction);
return;
}
if (action.getStatus() == Action.Status.FAILED) {
@@ -122,7 +123,7 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
action.setStatus(Action.Status.FAILED);
if (action instanceof ExecutableAction executableAction) {
ensureExecutableResult(executableAction, true, e.getLocalizedMessage());
blockManager.emitActionFinishedBlock(executableAction);
emitExecutableActionFinished(executableAction);
}
}
};
@@ -133,6 +134,15 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
action.setStatus(Action.Status.FAILED);
}
private void emitExecutableActionFinished(ExecutableAction executableAction) {
blockManager.emitActionFinishedBlock(executableAction);
cognitionCapability.initiateTurn(
"An executable action has finished. Check the latest action-finished state and inform the user of the outcome if it is relevant to the current conversation.",
executableAction.getSource(),
"action_planner"
);
}
private void handleStateAction(StateAction stateAction) {
if (closed.get()) {
return;
@@ -145,7 +155,6 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
actionCapability.putAction(executableAction);
val actionChain = executableAction.getActionChain();
val phaser = new Phaser();
if (!prepareExecutableAction(executableAction, actionChain)) {
return;
}
@@ -153,6 +162,7 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
blockManager.emitActionLaunchedBlock(executableAction);
val stageCursor = initStageCursor(executableAction, actionChain);
int finalCorrectionAttempts = 0;
while (true) {
val stageSelection = selectCurrentStage(executableAction, actionChain);
if (stageSelection.shouldReturn()) {
@@ -161,13 +171,16 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
if (stageSelection.shouldStop()) {
break;
}
val stageExecution = runCurrentStage(executableAction, phaser, stageCursor, stageSelection.metaActions());
val stageExecution = runCurrentStage(executableAction, stageCursor, stageSelection.metaActions());
if (stageExecution.closed()) {
return;
}
if (!applyStageCorrectionAndAdvance(executableAction, stageCursor, stageExecution)) {
if (finalCorrectionAttempts >= MAX_FINAL_CORRECTION_ATTEMPTS || !applyFinalCorrection(executableAction, stageCursor)) {
break;
}
finalCorrectionAttempts++;
}
}
finishExecutableAction(executableAction);
}
@@ -211,10 +224,10 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
private StageExecution runCurrentStage(
ExecutableAction executableAction,
Phaser phaser,
StageCursor stageCursor,
List<MetaAction> metaActions
) {
Phaser phaser = new Phaser();
val recognizerRecord = startRecognizerIfNeeded(executableAction, phaser);
val listeningRecord = executeAndListening(metaActions, phaser, executableAction);
phaser.awaitAdvance(listeningRecord.phase());
@@ -248,7 +261,7 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
shouldRunCorrector = recognizerResult != null && recognizerResult.isNeedCorrection();
}
if (shouldRunCorrector) {
val correctorInput = assemblyHelper.buildCorrectorInput(executableAction);
val correctorInput = assemblyHelper.buildCorrectorInput(executableAction, CorrectorInput.CheckMode.PROCESS_CHECK);
actionCorrector.execute(correctorInput)
.onSuccess(correctorResult -> {
actionCapability.handleInterventions(correctorResult.getMetaInterventionList(), executableAction);
@@ -267,6 +280,59 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
}
}
private boolean applyFinalCorrection(ExecutableAction executableAction, StageCursor stageCursor) {
val recognizerInput = assemblyHelper.buildCorrectorInput(executableAction, CorrectorInput.CheckMode.FINAL_CHECK);
val recognizerResult = actionCorrectionRecognizer.execute(recognizerInput)
.getOrDefault(new RecognizerResult());
if (!recognizerResult.isNeedCorrection()) {
return false;
}
val previousStage = executableAction.getExecutingStage();
val correctorResult = actionCorrector.execute(recognizerInput).getOrDefault(new CorrectorResult());
val interventions = correctorResult.getMetaInterventionList() == null
? new ArrayList<work.slhaf.partner.core.action.entity.intervention.MetaIntervention>()
: correctorResult.getMetaInterventionList();
if (!interventions.isEmpty()) {
actionCapability.handleInterventions(interventions, executableAction);
}
blockManager.emitActionCorrectionBlock(
executableAction,
recognizerResult.getReason() == null || recognizerResult.getReason().isBlank()
? correctorResult.getCorrectionReason()
: recognizerResult.getReason(),
interventions
);
synchronized (executableAction.getExecutionLock()) {
if (executableAction.getStatus() == Action.Status.FAILED) {
return false;
}
if (interventions.isEmpty() || executableAction.getActionChain().isEmpty()) {
executableAction.setStatus(Action.Status.FAILED);
ensureExecutableResult(executableAction, true, "行动未达到目标,且未能生成可继续执行的纠偏行动");
return false;
}
if (executableAction.getStatus() == Action.Status.PREPARE) {
normalizeExecutingStage(executableAction, executableAction.getActionChain());
executableAction.setStatus(Action.Status.EXECUTING);
} else {
Integer nextStage = executableAction.getActionChain().keySet().stream()
.filter(stage -> stage > previousStage)
.min(Integer::compareTo)
.orElse(null);
if (nextStage == null) {
executableAction.setStatus(Action.Status.FAILED);
ensureExecutableResult(executableAction, true, "行动未达到目标,纠偏未生成后续执行阶段");
return false;
}
executableAction.setExecutingStage(nextStage);
}
stageCursor.refresh();
return true;
}
}
private void finishExecutableAction(ExecutableAction executableAction) {
// 如果是 ScheduledActionData, 则重置 ActionData 内容,记录执行历史与最终结果
if (executableAction instanceof SchedulableExecutableAction scheduledActionData) {
@@ -339,7 +405,12 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
result.reset();
metaAction.getParams().clear();
Result<ExtractorInput> extractorInputResult = assemblyHelper.buildExtractorInput(metaAction.getKey(), actionData.getUuid(), actionData.getDescription());
Result<ExtractorInput> extractorInputResult = assemblyHelper.buildExtractorInput(
metaAction.getKey(),
actionData.getUuid(),
actionData.getDescription(),
resolveCurrentStageDescription(actionData, executingStage)
);
AgentRuntimeException exception = extractorInputResult.exceptionOrNull();
if (exception != null) {
failureReason.set(exception.getMessage());
@@ -413,7 +484,7 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
if (!shouldRunCorrectionRecognizer(executableAction)) {
return RecognizerTaskRecord.disabled();
}
val recognizerInput = assemblyHelper.buildCorrectorInput(executableAction);
val recognizerInput = assemblyHelper.buildCorrectorInput(executableAction, CorrectorInput.CheckMode.PROCESS_CHECK);
val task = buildRecognizerTask(recognizerInput, phaser);
Future<RecognizerResult> future = virtualExecutor.submit(task);
return new RecognizerTaskRecord(true, future);
@@ -445,6 +516,10 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
}
}
private String resolveCurrentStageDescription(ExecutableAction executableAction, int executingStage) {
return executableAction.getStageDescriptions().get(executingStage);
}
private String buildAttemptFailureReason(String prefix, String detail) {
if (detail == null || detail.isBlank()) {
return prefix;
@@ -655,6 +730,17 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
return stageCount < actionChain.size();
}
private void refresh() {
val orderList = new ArrayList<>(actionChain.keySet());
orderList.sort(Integer::compareTo);
stageCount = orderList.indexOf(executableAction.getExecutingStage());
if (stageCount < 0) {
stageCount = 0;
}
executingStageUpdated = false;
stageCountUpdated = false;
}
private void update() {
val orderList = new ArrayList<>(actionChain.keySet());
orderList.sort(Integer::compareTo);
@@ -667,32 +753,36 @@ public class ActionExecutor extends AbstractAgentModule.Standalone {
private AssemblyHelper() {
}
private Result<ExtractorInput> buildExtractorInput(String actionKey, @NotNull String uuid, @NotNull String description) {
private Result<ExtractorInput> buildExtractorInput(String actionKey, @NotNull String uuid, @NotNull String description, String currentStageDescription) {
return actionCapability.loadMetaActionInfo(actionKey).fold(
metaActionInfo -> {
ExtractorInput input = new ExtractorInput();
input.setMetaActionInfo(metaActionInfo);
input.setTargetActionId(uuid);
input.setTargetActionDesc(description);
input.setCurrentStageDesc(currentStageDescription);
return Result.success(input);
},
Result::failure
);
}
private CorrectorInput buildCorrectorInput(ExecutableAction executableAction) {
private CorrectorInput buildCorrectorInput(ExecutableAction executableAction, CorrectorInput.CheckMode checkMode) {
Map<Integer, List<CorrectorInput.ActionChainItem>> overview = new LinkedHashMap<>();
executableAction.getActionChain().forEach((stage, list) -> {
List<CorrectorInput.ActionChainItem> overviewItems = list.stream()
.map(metaAction -> new CorrectorInput.ActionChainItem(
metaAction.getKey(),
resolveHistoryDescription(metaAction.getKey()),
metaAction.getResult().getStatus().name().toLowerCase(Locale.ROOT)
executableAction.getStageDescriptions().get(stage),
metaAction.getResult().getStatus().name().toLowerCase(Locale.ROOT),
metaAction.getResult().getData()
))
.toList();
overview.put(stage, overviewItems);
});
return CorrectorInput.builder()
.checkMode(checkMode)
.tendency(executableAction.getTendency())
.source(executableAction.getSource())
.reason(executableAction.getReason())
@@ -9,6 +9,7 @@ import work.slhaf.partner.core.action.entity.intervention.MetaIntervention;
import work.slhaf.partner.core.cognition.BlockContent;
import work.slhaf.partner.core.cognition.ContextBlock;
import work.slhaf.partner.core.cognition.ContextWorkspace;
import work.slhaf.partner.module.StateHintContent;
import work.slhaf.partner.module.action.executor.entity.HistoryAction;
import java.time.ZonedDateTime;
@@ -98,8 +99,8 @@ class ExecutingActionBlockManager {
buildStateActionCompactBlock(snapshot, blockName, emittedAt, event),
buildStateActionAbstractBlock(snapshot, blockName, event),
Set.of(ContextBlock.FocusedDomain.ACTION),
70,
18,
90,
30,
10
));
}
@@ -382,6 +383,7 @@ class ExecutingActionBlockManager {
14,
24
));
contextWorkspace.register(buildActionFinishedStateHintBlock(snapshot, emittedAt, event));
}
private @NotNull BlockContent buildActionFinishedAbstractBlock(ExecutableActionSnapshot snapshot, String blockName, String event) {
@@ -406,6 +408,24 @@ class ExecutingActionBlockManager {
};
}
private @NotNull ContextBlock buildActionFinishedStateHintBlock(ExecutableActionSnapshot snapshot, String emittedAt, String event) {
return StateHintContent.createBlock(new StateHintContent(
SOURCE,
"An executable action has finished."
) {
@Override
public void fillStateContent(@NotNull Document document, @NotNull Element stateElement) {
appendTextElement(document, stateElement, "event", event);
appendTextElement(document, stateElement, "emitted_at", emittedAt);
appendTextElement(document, stateElement, "action_id", snapshot.getUuid());
appendTextElement(document, stateElement, "final_status", snapshot.getStatus().name().toLowerCase(Locale.ROOT));
appendTextElement(document, stateElement, "description", snapshot.getDescription());
appendTextElement(document, stateElement, "result", snapshot.getResult());
appendTextElement(document, stateElement, "source_user", snapshot.getSource());
}
});
}
private String emittedAt() {
return ZonedDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
@@ -29,7 +29,7 @@ public class ParamsExtractor extends AbstractAgentModule.Sub<ExtractorInput, Res
你会收到:
- 一条结构化上下文消息,其中可能包含当前行动相关状态、近期交流轨迹、以及活跃记忆切片;
- 一条任务消息,其中包含:
- target_action:本次参数提取所面向的目标行动,用于帮助你判断上下文中哪些内容与当前提取直接相关;
- target_action:本次参数提取所面向的目标行动,用于帮助你判断上下文中哪些内容与当前提取直接相关;其中 current_stage_description 表示本次 MetaAction 所属阶段的具体执行目标,应优先用于约束参数提取;
- meta_action_info:该行动对应的说明,以及允许提取的参数列表。每个 <param name="..."> 节点的文本内容表示该参数的含义或期望内容。
你的任务:
@@ -78,6 +78,9 @@ public class ParamsExtractor extends AbstractAgentModule.Sub<ExtractorInput, Res
appendChildElement(document, root, "target_action", block -> {
appendTextElement(document, block, "uuid", input.getTargetActionId());
appendTextElement(document, block, "description", input.getTargetActionDesc());
if (input.getCurrentStageDesc() != null && !input.getCurrentStageDesc().isBlank()) {
appendTextElement(document, block, "current_stage_description", input.getCurrentStageDesc());
}
return Unit.INSTANCE;
});
appendChildElement(document, root, "meta_action_info", element -> {
@@ -99,6 +102,7 @@ public class ParamsExtractor extends AbstractAgentModule.Sub<ExtractorInput, Res
return cognitionCapability.contextWorkspace()
.resolve(List.of(
ContextBlock.FocusedDomain.ACTION,
ContextBlock.FocusedDomain.PERCEIVE,
ContextBlock.FocusedDomain.COMMUNICATION,
ContextBlock.FocusedDomain.MEMORY
))
@@ -10,6 +10,7 @@ import java.util.Map;
@Data
@Builder
public class CorrectorInput {
private CheckMode checkMode;
private String tendency;
private String source;
private String reason;
@@ -18,11 +19,18 @@ public class CorrectorInput {
private Map<Integer, List<ActionChainItem>> actionChainOverview;
public enum CheckMode {
PROCESS_CHECK,
FINAL_CHECK
}
@Data
@AllArgsConstructor
public static class ActionChainItem {
private String actionKey;
private String description;
private String stageDescription;
private String status;
private String result;
}
}
@@ -16,6 +16,11 @@ public class ExtractorInput {
*/
private String targetActionDesc;
/**
* 当前执行阶段的 description
*/
private String currentStageDesc;
/**
* 目标 MetaActionInfo
*/
@@ -2,6 +2,7 @@ package work.slhaf.partner.module.action.executor.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@@ -12,6 +13,7 @@ public class ExtractorResult {
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ParamEntry {
private String name;
private String value;
@@ -10,7 +10,6 @@ import work.slhaf.partner.core.action.ActionCore;
import work.slhaf.partner.core.action.entity.*;
import work.slhaf.partner.core.cognition.BlockContent;
import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.CommunicationBlockContent;
import work.slhaf.partner.core.cognition.ContextBlock;
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException;
import work.slhaf.partner.framework.agent.exception.ExceptionReporterHandler;
@@ -19,6 +18,7 @@ import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAg
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule;
import work.slhaf.partner.framework.agent.support.Result;
import work.slhaf.partner.module.StateHintContent;
import work.slhaf.partner.module.action.executor.ActionExecutor;
import work.slhaf.partner.module.action.planner.evaluator.ActionEvaluator;
import work.slhaf.partner.module.action.planner.evaluator.entity.EvaluatorInput;
@@ -42,8 +42,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
public class ActionPlanner extends AbstractAgentModule.Running<PartnerRunningFlowContext> {
private static final String IMMEDIATE_WATCHER_CRON = "0/5 * * * * ?";
private static final String BLOCK_SOURCE = "action_planner_pending";
private static final String TENDENCIES_EVALUATING_BLOCK_NAME = "tendencies_in_evaluating";
private static final String TENDENCIES_EVALUATING_BLOCK_NAME = "pending_action_intentions";
private final ActionAssemblyHelper assemblyHelper = new ActionAssemblyHelper();
@@ -83,99 +82,89 @@ public class ActionPlanner extends AbstractAgentModule.Running<PartnerRunningFlo
return;
}
appendTendencyBlock(tendencies);
evaluateTendency(context.getSource(), input, extractorResult);
evaluateTendency(context.getSource(), extractorResult);
}
private void appendTendencyBlock(List<String> tendencies) {
String datetime = ZonedDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
cognitionCapability.contextWorkspace().register(new ContextBlock(
buildTendenciesEvaluatingFullBlock(tendencies),
buildTendenciesEvaluatingCompactBlock(tendencies, datetime),
buildTendenciesEvaluatingCompactBlock(tendencies, datetime),
buildTendenciesEvaluatingAbstractBlock(tendencies, datetime),
Set.of(ContextBlock.FocusedDomain.ACTION),
60,
18,
4
60, 18, 4
));
cognitionCapability.contextWorkspace().register(StateHintContent.createBlock(new StateHintContent(
TENDENCIES_EVALUATING_BLOCK_NAME,
"Partner intends to handle the latest input through actions. When replying to the user, present this as Partner preparing to take action, not as an internal evaluation process."
) {
@Override
public void fillStateContent(@NotNull Document document, @NotNull Element stateElement) {
appendListElement(document, stateElement, "action_intentions", "action_intention", tendencies);
}
}));
}
private @NotNull BlockContent buildTendenciesEvaluatingAbstractBlock(List<String> tendencies, String datetime) {
return new BlockContent(TENDENCIES_EVALUATING_BLOCK_NAME, getModuleName(), BlockContent.Urgency.HIGH) {
return new BlockContent(TENDENCIES_EVALUATING_BLOCK_NAME, getModuleName()) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "datetime", datetime);
appendTextElement(document, root, "abstract", "There are " + tendencies.size() + " related tendencies in evaluating.");
appendTextElement(document, root, "abstract", "There are " + tendencies.size() + " action intentions that Partner is preparing to handle.");
}
};
}
private @NotNull BlockContent buildTendenciesEvaluatingCompactBlock(List<String> tendencies, String datetime) {
return new BlockContent(TENDENCIES_EVALUATING_BLOCK_NAME, getModuleName(), BlockContent.Urgency.HIGH) {
return new BlockContent(TENDENCIES_EVALUATING_BLOCK_NAME, getModuleName()) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
int size = tendencies.size();
boolean num = size > 3;
appendTextElement(document, root, "datetime", datetime);
appendTextElement(document, root, "state", "Partner is considering whether to do these");
appendTextElement(document, root, "tendencies_count", size);
appendListElement(document, root, num ? "tendencies_truncated" : "tendencies", "tendency", num ? tendencies.subList(0, 3) : tendencies);
}
};
}
private @NotNull BlockContent buildTendenciesEvaluatingFullBlock(List<String> tendencies) {
return new BlockContent(TENDENCIES_EVALUATING_BLOCK_NAME, getModuleName(), BlockContent.Urgency.HIGH) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "state", "Partner is considering whether to do these");
appendListElement(document, root, "tendencies", "tendency", tendencies);
}
};
}
private void evaluateTendency(String source, String input, ExtractorResult extractorResult) {
private void evaluateTendency(String source, ExtractorResult extractorResult) {
executor.execute(() -> {
EvaluatorInput evaluatorInput = assemblyHelper.buildEvaluatorInput(extractorResult);
List<EvaluatorResult> evaluatorResults = actionEvaluator.execute(evaluatorInput); // 并发操作均为访问
handleEvaluatorResults(evaluatorResults, source, input);
handleEvaluatorResults(evaluatorResults, source);
cognitionCapability.contextWorkspace().expire(TENDENCIES_EVALUATING_BLOCK_NAME, getModuleName());
});
}
private void handleEvaluatorResults(List<EvaluatorResult> evaluatorResults, String source, String input) {
List<ExecutableAction> passedActions = new ArrayList<>();
int approvedExecutableCount = 0;
int pendingConfirmCount = 0;
private void handleEvaluatorResults(List<EvaluatorResult> evaluatorResults, String source) {
boolean hasPendingConfirmation = false;
List<String> refusedTendencies = new ArrayList<>();
for (EvaluatorResult evaluatorResult : evaluatorResults) {
expireResolvedPending(evaluatorResult);
if (!evaluatorResult.isOk()) {
refusedTendencies.add(evaluatorResult.getTendency());
continue;
}
ExecutableAction executableAction = assemblyHelper.buildActionData(evaluatorResult, source);
if (executableAction == null) {
continue;
}
passedActions.add(executableAction);
if (evaluatorResult.isNeedConfirm()) {
registerPendingContextBlock(executableAction, evaluatorResult, input);
pendingConfirmCount++;
hasPendingConfirmation = true;
registerPendingContextBlock(executableAction, evaluatorResult);
continue;
}
executeOrSchedule(executableAction);
approvedExecutableCount++;
}
if (passedActions.isEmpty()) {
return;
if (!refusedTendencies.isEmpty()) {
cognitionCapability.initiateTurn("Some candidate action tendencies were refused during evaluation. Only reply if the user has not already received a suitable response and one of the refused tendencies was explicitly promised or implied as executable. If a response has already been given, or no promise was made, respond with NO_REPLY.", source, getModuleName());
}
createTurn(approvedExecutableCount, pendingConfirmCount, source, input);
if (hasPendingConfirmation) {
cognitionCapability.initiateTurn("Some actions are pending user confirmation. Ask the user to confirm before executing them.", source, getModuleName());
}
private void createTurn(int approvedExecutableCount, int pendingConfirmCount, String source, String input) {
String turnInput = approvedExecutableCount + " actions are approved for execution, " +
pendingConfirmCount + " actions are waiting for confirmation, " +
"according to input: " + trimInput(input) + ". For more information, please refer to the context content or other tags in this input block.";
cognitionCapability.initiateTurn(turnInput, source, getModuleName());
}
private void expireResolvedPending(EvaluatorResult evaluatorResult) {
@@ -192,47 +181,30 @@ public class ActionPlanner extends AbstractAgentModule.Running<PartnerRunningFlo
);
}
private void registerPendingContextBlock(ExecutableAction executableAction, EvaluatorResult evaluatorResult, String input) {
String blockName = buildPendingBlockName(executableAction);
input = trimInput(input);
ContextBlock block = new ContextBlock(
buildPendingBlock(blockName, executableAction, evaluatorResult),
buildPendingCompactBlock(blockName, executableAction, evaluatorResult, input),
buildPendingAbstractBlock(blockName, executableAction, evaluatorResult, input),
Set.of(ContextBlock.FocusedDomain.ACTION),
30,
10,
5
);
cognitionCapability.contextWorkspace().register(block);
}
private String buildPendingBlockName(ExecutableAction executableAction) {
return "pending_action-" + executableAction.getUuid();
}
private BlockContent buildPendingBlock(String blockName, ExecutableAction executableAction, EvaluatorResult evaluatorResult) {
return new CommunicationBlockContent(blockName, BLOCK_SOURCE, BlockContent.Urgency.HIGH, CommunicationBlockContent.Projection.SUPPLY) {
private void registerPendingContextBlock(ExecutableAction executableAction, EvaluatorResult evaluatorResult) {
cognitionCapability.contextWorkspace().register(StateHintContent.createBlock(new StateHintContent(
"actions_need_confirmation",
"Partner is waiting for user confirmation before executing a pending action."
) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "state", "need_user_confirm");
appendTextElement(document, root, "action_uuid", executableAction.getUuid());
appendTextElement(document, root, "action_type", evaluatorResult.getType());
appendTextElement(document, root, "tendency", executableAction.getTendency());
appendTextElement(document, root, "reason", executableAction.getReason());
appendTextElement(document, root, "description", executableAction.getDescription());
appendTextElement(document, root, "source_user", executableAction.getSource());
public void fillStateContent(@NotNull Document document, @NotNull Element stateElement) {
appendTextElement(document, stateElement, "state", "need_user_confirm");
appendTextElement(document, stateElement, "action_type", evaluatorResult.getType());
appendTextElement(document, stateElement, "tendency", executableAction.getTendency());
appendTextElement(document, stateElement, "reason", executableAction.getReason());
appendTextElement(document, stateElement, "description", executableAction.getDescription());
appendTextElement(document, stateElement, "source_user", executableAction.getSource());
EvaluatorResult.ScheduleData scheduleData = evaluatorResult.getScheduleData();
if (scheduleData != null) {
appendTextElement(document, root, "schedule_type", scheduleData.getType());
appendTextElement(document, root, "schedule_content", scheduleData.getContent());
appendTextElement(document, stateElement, "schedule_type", scheduleData.getType());
appendTextElement(document, stateElement, "schedule_content", scheduleData.getContent());
}
Map<Integer, List<String>> primaryActionChain = evaluatorResult.getPrimaryActionChainAsMap();
if (primaryActionChain == null || primaryActionChain.isEmpty()) {
return;
}
Element chainRoot = document.createElement("primary_action_chain");
root.appendChild(chainRoot);
stateElement.appendChild(chainRoot);
List<Integer> orders = new ArrayList<>(primaryActionChain.keySet());
orders.sort(Integer::compareTo);
for (Integer order : orders) {
@@ -247,33 +219,7 @@ public class ActionPlanner extends AbstractAgentModule.Running<PartnerRunningFlo
);
}
}
};
}
private BlockContent buildPendingCompactBlock(String blockName, ExecutableAction executableAction, EvaluatorResult evaluatorResult, String input) {
return new CommunicationBlockContent(blockName, BLOCK_SOURCE, BlockContent.Urgency.HIGH, CommunicationBlockContent.Projection.SUPPLY) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "state", "need_user_confirm");
appendTextElement(document, root, "related_input", input);
appendTextElement(document, root, "tendency", executableAction.getTendency());
appendTextElement(document, root, "description", executableAction.getDescription());
appendTextElement(document, root, "action_type", evaluatorResult.getType());
}
};
}
private BlockContent buildPendingAbstractBlock(String blockName, ExecutableAction executableAction, EvaluatorResult evaluatorResult, String input) {
return new BlockContent(blockName, BLOCK_SOURCE, BlockContent.Urgency.HIGH) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "state", "need_user_confirm");
appendTextElement(document, root, "related_input", input);
appendTextElement(document, root, "pending_tendency", executableAction.getTendency());
appendTextElement(document, root, "summary", "exists pending action waiting for confirmation");
appendTextElement(document, root, "action_type", evaluatorResult.getType());
}
};
}));
}
private void executeOrSchedule(ExecutableAction executableAction) {
@@ -306,7 +252,6 @@ public class ActionPlanner extends AbstractAgentModule.Running<PartnerRunningFlo
if (!notified.compareAndSet(false, true)) {
return Unit.INSTANCE;
}
watcherSelfTalk(action);
return Unit.INSTANCE;
})
);
@@ -314,34 +259,11 @@ public class ActionPlanner extends AbstractAgentModule.Running<PartnerRunningFlo
actionScheduler.schedule(watcher);
}
private void watcherSelfTalk(ImmediateExecutableAction action) {
String result = action.getResult();
String structuredSignal = String.format(
"{event=immediate_action_finished,actionUuid=%s,tendency=%s,status=%s,source=%s,result=%s}",
action.getUuid(),
action.getTendency(),
action.getStatus(),
action.getSource(),
result == null ? "" : result //将会在 ActionExecutor
);
try {
cognitionCapability.initiateTurn(structuredSignal, action.getSource());
} catch (Exception e) {
log.warn("触发 immediate 行动完成自对话失败, actionUuid: {}", action.getUuid(), e);
}
}
@Override
public int order() {
return 2;
}
private String trimInput(@NotNull String input) {
input = input.trim();
input = input.length() <= 100 ? input : input.substring(0, 100);
return input;
}
private final class ActionAssemblyHelper {
private ActionAssemblyHelper() {
}
@@ -357,7 +279,7 @@ public class ActionPlanner extends AbstractAgentModule.Running<PartnerRunningFlo
if (actionChain == null) {
return null;
}
return switch (evaluatorResult.getType()) {
ExecutableAction executableAction = switch (evaluatorResult.getType()) {
case PLANNING -> new SchedulableExecutableAction(
evaluatorResult.getTendency(),
actionChain,
@@ -375,6 +297,27 @@ public class ActionPlanner extends AbstractAgentModule.Running<PartnerRunningFlo
userId
);
};
executableAction.getStageDescriptions().putAll(getStageDescriptions(evaluatorResult));
return executableAction;
}
private Map<Integer, String> getStageDescriptions(EvaluatorResult evaluatorResult) {
Map<Integer, String> descriptions = new HashMap<>();
List<EvaluatorResult.ChainElement> primaryActionChain = evaluatorResult.getPrimaryActionChain();
if (primaryActionChain == null || primaryActionChain.isEmpty()) {
return descriptions;
}
List<EvaluatorResult.ChainElement> orderedChain = new ArrayList<>(primaryActionChain);
orderedChain.sort(Comparator.comparing(EvaluatorResult.ChainElement::getOrder, Comparator.nullsLast(Integer::compareTo)));
int fixedOrder = 1;
for (EvaluatorResult.ChainElement chainElement : orderedChain) {
String description = chainElement.getDescription();
if (description != null && !description.isBlank()) {
descriptions.put(fixedOrder, description);
}
fixedOrder++;
}
return descriptions;
}
private Map<Integer, List<MetaAction>> getActionChain(EvaluatorResult evaluatorResult) {
@@ -399,6 +342,9 @@ public class ActionPlanner extends AbstractAgentModule.Running<PartnerRunningFlo
}
private boolean fixDependencies(Map<Integer, List<String>> primaryActionChain) {
if (primaryActionChain == null || primaryActionChain.isEmpty()) {
return false;
}
// 先将 primaryActionChain 的节点序号修正为从1开始依次增大
fixOrder(primaryActionChain);
List<Integer> fixedOrders = new ArrayList<>(primaryActionChain.keySet().stream().toList());
@@ -409,6 +355,9 @@ public class ActionPlanner extends AbstractAgentModule.Running<PartnerRunningFlo
for (Integer fixedOrder : fixedOrders) {
int lastOrder = fixedOrder - 1;
List<String> actionKeys = primaryActionChain.get(fixedOrder);
if (actionKeys == null || actionKeys.isEmpty()) {
continue;
}
for (String actionKey : actionKeys) {
// 根据 actionKey 加载行动信息,并检查是否存在必需前置依赖
@@ -451,9 +400,12 @@ public class ActionPlanner extends AbstractAgentModule.Running<PartnerRunningFlo
private void fixOrder(Map<Integer, List<String>> primaryActionChain) {
Map<Integer, List<String>> tempChain = new HashMap<>(primaryActionChain);
primaryActionChain.clear();
int chainSize = tempChain.size();
for (int i = 0; i < chainSize; i++) {
primaryActionChain.put(i, tempChain.get(i));
List<Integer> orders = new ArrayList<>(tempChain.keySet());
orders.sort(Integer::compareTo);
int fixedOrder = 1;
for (Integer order : orders) {
List<String> actionKeys = tempChain.get(order);
primaryActionChain.put(fixedOrder++, actionKeys == null ? new ArrayList<>() : new ArrayList<>(actionKeys));
}
}
@@ -26,51 +26,174 @@ import java.util.concurrent.ExecutorService;
public class ActionEvaluator extends AbstractAgentModule.Sub<EvaluatorInput, List<EvaluatorResult>> implements ActivateModel {
private static final String MODULE_PROMPT = """
你负责评估单条行动倾向,并判断它是否值得进入后续行动链。
你负责评估单条行动倾向 tendency,并判断它在当前系统状态下是否可以进入后续行动链。
你会收到:
- 一条结构化上下文消息,其中可能包含当前活跃的行动相关状态、近期交流轨迹、认知相关上下文、以及当前活跃记忆切片;
- 一组 <available_meta_actions>,表示当前可用的 MetaAction 候选;
- 一条最新的 user message,其内容就是当前需要评估的单条 tendency。
- 一条结构化上下文消息,其中可能包含 action、communication、perceive、memory、cognition 等上下文;
- 一组 <available_meta_actions>,表示当前系统真实可用的 MetaAction 候选;
- 一条 user message,其内容是上游 ActionExtractor 提取出的单条 tendency。
重要前提:
- 你的输入 tendency 已经由 ActionExtractor 根据当前输入和上下文提取完成。
- 你不负责重新判断 tendency 是否来自最新用户输入。
- 你不负责重新做意图抽取、指代解析或上下文对齐。
- recent_chatmessage 中的最后一条不一定是最新输入,可能只是上一轮对话记录;不要依赖它来否定 tendency。
- 除非上下文中存在明确证据表明 tendency 已过期、已被取消、已被覆盖或与当前行动状态冲突,否则应把 tendency 当作当前待评估的候选行动。
你的任务:
- 判断该 tendency 是否成立、是否值得推进;
- 判断它更适合立即执行,还是先进入规划;
- 判断是否需要先向用户确认;
- 若可推进,则基于 available_meta_actions 生成 primaryActionChain;
- 若当前 tendency 与某个待处理 pending 明确对应,且本轮已完成承接或推进,应正确填写 resolvedPending;
- 若当前 tendency 明确包含可调度语义,再填写 scheduleData。
- 判断该 tendency 在当前系统状态下是否可推进;
- 判断完成该 tendency 是否需要行动链;
- 判断 available_meta_actions 是否能承接该 tendency;
- 判断是否需要用户确认;
- 判断该 tendency 更适合立即执行还是进入规划;
- 输出严格符合 EvaluatorResult 的评估结果。
评估原则:
- 结合上下文理解当前 tendency 与近期交流、当前行动状态、活跃记忆之间的关系。
- 若 tendency 与已有正在执行、等待确认、或已明确覆盖的行动完全等价,通常不应重复建立新的行动链。
- 若 tendency 是对已有待确认行动的确认、拒绝、补充条件、修改要求或继续推进,则应优先视为对原有行动状态的承接,而不是无关新任务。
- 若 action 相关上下文中存在等待确认的 block,且当前 tendency 明显与其相关,则必须显式承接这一点,不要因为其已存在于上下文中而省略。
- 只有在 tendency 明确具有可执行意义时,才返回 ok=true。
- 若 tendency 更适合直接交流回应,或尚不足以形成行动推进,则返回 ok=false。
- primaryActionChain 中只能使用 available_meta_actions 内出现的 action_key,不要编造不存在的动作。
- 不要输出完整执行细节、自然语言计划正文或额外解释,只输出 EvaluatorResult 对应结构。
核心原则:
- 不要把“需要确认”误判成“不可执行”。
- 不要把“有风险”误判成“用户没有要求”。
- 不要把“能力不足”误判成“用户没有要求”。
- 不要因为 recent_chatmessage 没有出现对应内容就否定 tendency。
- 不要根据上一轮聊天内容推翻当前 tendency。
- 只有在可行性、能力、风险、状态覆盖、信息完整性等评估层面拒绝 tendency。
关于字段:
- ok 表示该 tendency 是否值得进入后续行动推进。
- needConfirm 表示在真正推进前是否必须先得到用户确认。
- type:
- IMMEDIATE: 可直接进入即时执行链;
- PLANNING: 需要先形成或进入规划链,再决定后续执行。
- primaryActionChain 表示按 order 分组的候选动作链,每个元素包含:
- order: 执行顺序
- actionKeys: 该顺序下的 action_key 列表
- reason 用于说明你为何做出该判断,应简洁明确。
- description 用于概括本次行动评估结果,应能帮助后续模块快速理解该 tendency 的推进方向。
- scheduleData 仅在该 tendency 明确包含可调度语义时填写;否则留空。
- scheduleData.type: 一次性计划或周期性计划
- scheduleData.content: 符合 Quartz 标准的 Cron 表达式
- resolvedPending 仅在你能明确判断当前 tendency 已承接某个 pending block 时填写;否则留空。
- 当 ok=false 时,type、primaryActionChain、scheduleData、resolvedPending 通常应留空或保持无效默认值,不要强行填充。
决策流程:
0. 优先处理 pending action 的确认、拒绝、修改或取消
- 如果 action 上下文中存在等待用户确认的 pending action,且 tendency 表达的是确认、同意、继续、执行、可以、拒绝、取消、修改、补充条件等承接行为,应优先按 pending action 承接处理。
- 这类 tendency 不是新的独立行动,不要为“确认、拒绝、继续”本身寻找 available_meta_actions。
- 不要因为 pending action 有副作用就再次设置 needConfirm=true;用户确认 pending action 时,needConfirm 必须为 false,避免确认循环。
- 若 tendency 表示确认、同意、继续或执行 pending action:
- 应返回 ok=true;
- needConfirm=false;
- resolvedPending 应填写对应 pending block 的 blockName 与 source;
- primaryActionChain 应从 pending action 的 primary_action_chain 承接;
- type 应从 pending action 的 action_type 承接;
- scheduleData 应从 pending action 的 schedule 信息承接;若 pending action 没有调度信息则留空;
- reason 应说明用户已确认该 pending action,现在可以执行。
- 若 tendency 表示拒绝、取消或不执行 pending action:
- 应填写 resolvedPending;
- ok=false;
- reason 应说明用户取消了 pending action,因此不再推进;
- 不要生成 primaryActionChain。
- 若 tendency 表示修改 pending action、补充条件或改变目标:
- 应填写 resolvedPending;
- 不要把它当作全新无关任务;
- 若修改后的目标足够明确且 available_meta_actions 可承接,则按修改后的行动返回 ok=true;
- 若信息不足,则 ok=false,并说明需要澄清。
- 只有当 action 上下文中不存在相关 pending action,或 tendency 明显不是对 pending action 的承接时,才继续后续可行性评估。
1. 判断是否已经被现有 action 状态覆盖
- 若 tendency 与已有正在执行、等待确认、已完成且仍有效的 action 完全等价,通常不应重复建立行动链。
- 若 tendency 与现有 action 相关但不是重复,而是追加约束、改变目标或要求继续,应按新的行动需求评估。
- 若拒绝是因为已有 action 覆盖,ok=false,并在 reason 中说明覆盖关系。
- 不要仅因为 conversation 中存在相似历史内容就认为已覆盖;必须有 action 状态证据。
2. 判断是否需要行动链
按照完成条件判断,而不是按行为类别死板判断:
- 如果完成 tendency 需要系统获取当前状态、访问资源、调用能力、操作对象、改变状态、触发过程、安排未来动作或向外部系统发出请求,则通常需要行动链。
- 如果完成 tendency 只需要基于已有上下文进行解释、分析、总结、评价、翻译、改写、闲聊或普通交流,且不需要任何系统能力介入,则通常不需要行动链。
- 如果 tendency 的完成结果依赖“实际执行后发生什么”“当前对象真实状态是什么”“外部资源现在是什么结果”,则通常需要行动链。
- 如果用户目标本身是让系统代为完成某件事,而不是只要建议、说明或解释,则通常需要行动链。
这里的“对象”可以是任何可被系统能力作用的东西,包括本地资源、远程资源、应用状态、文件系统、运行环境、记忆、计划、任务、接口、页面、数据、设备或会话状态。
这里的“产生影响”包括改变对象状态、创建结果、触发过程、安排未来动作或向外部系统发出请求。
不要试图枚举所有用户行为;依据“是否需要系统能力介入才能完成”来判断。
3. 判断 available_meta_actions 是否能承接
ok=true 的必要条件:
- tendency 当前没有被已有 action 状态覆盖;
- 完成 tendency 需要行动链;
- available_meta_actions 中存在能承接该 tendency 的 action_key;
- 可以生成非空 primaryActionChain。
规则:
- ok=true 时 primaryActionChain 必须非空。
- primaryActionChain 只能使用 available_meta_actions 中真实存在的 action_key。
- 不要编造 action_key。
- 对 pending action 的确认行为,不要为“确认”本身寻找 action_key;应从 pending action 中承接原 action chain。
- 如果没有任何可用能力能承接,即使 tendency 本身合理,也必须 ok=false。
- 如果无法承接,reason 应说明缺少能力、能力不匹配、信息不足或策略限制,不要说用户没有要求。
4. 判断信息是否足够
- 若 tendency 的目标、对象、范围、参数或约束足够明确,可以继续评估可执行性。
- 若缺少必要信息,但可以通过向用户确认或澄清补足,应 ok=false,并说明需要澄清。
- 若缺少的信息可以通过已有 action/context/pending 状态明确补足,则可以继续推进。
- 不要因为 tendency 中的信息来自上游解析或上下文补全就认为信息不足;只判断当前 tendency 自身是否足以形成行动链。
5. 判断是否需要确认
needConfirm 判断的是“是否需要先获得用户确认”,不是“是否能执行”。
- needConfirm=true 仍然可以 ok=true。
- 如果当前 tendency 是对已有 pending action 的确认、同意、继续或执行,needConfirm 必须为 false;确认行为不能再次触发确认。
- 若行动可能产生副作用、不可逆影响、权限风险、隐私风险、安全风险、资源消耗、外部可见影响、长期运行影响,或用户可能希望先确认,则应 needConfirm=true。
- 若行动是低风险、短时、可逆、范围明确、授权清晰,且系统策略允许直接推进,则可 needConfirm=false。
- 如果风险或目标不确定到无法形成可确认行动,应 ok=false,并说明需要澄清或缺少必要信息。
- 不要因为行动有副作用就直接拒绝;先判断是否可以通过 needConfirm 承接。
- 不要把“需要确认”写成“无法执行”。
6. 判断 type
- IMMEDIATE:目标清楚,能力链清楚,可以直接由当前 action chain 推进。
- PLANNING:目标需要拆解、多步协调、长期推进、条件判断或中间决策。
- ok=false 时,type 不应表达可执行状态;若结构必须填默认值,不要在 reason/description 中暗示会执行。
7. 输出字段要求
primaryActionChain:
- 只在 ok=true 时填写。
- 每个元素包含 order、description 和 actionKeys。
- description 是该 stage 的具体执行目标,用一句短句说明本阶段要获得、检查、修改或触发什么;它不是用户回复文案,也不是完整推理过程。
- 不要写伪代码。
- 若 tendency 包含多个依赖步骤、需要先获取 A 再根据 A 决定 B、需要多次调用同一能力处理不同对象,或需要为后续汇报获取多类事实,应输出多 order 的 primaryActionChain。
- 不要为了减少步骤而把复杂诊断、查找、读取、分析全部压缩成单个 command::execute。
- 同一个 available_meta_action 可以在不同 order 中重复使用,只要每次对应的阶段目标、参数或对象不同。
- 对需要最终汇报的任务,行动链应负责获取支撑汇报所需的事实;最终自然语言汇报由 Communication 基于 action-finished state 完成,不需要把“汇报”本身作为一个 MetaAction。
scheduleData:
- 仅当 tendency 明确要求未来、周期、延迟、提醒、定时或计划安排时填写。
- 没有调度语义时留空。
- 不要为了完整性强行填写。
resolvedPending:
- 仅当当前 tendency 明确承接某个 pending block 时填写。
- 无法明确对应时留空。
- 不要猜测。
reason:
- 说明可行性判断依据。
- 若 ok=true,说明为什么当前可以推进、使用能力链是否充分、是否需要确认。
- 若 ok=false,必须说明真正失败的层级:
- 不需要行动链,直接交流即可;
- 已有 action 覆盖;
- 缺少可用 MetaAction;
- action_key 无法承接;
- 必要信息不足;
- 指代或目标不明确;
- 风险/权限/策略限制导致无法推进;
- 需要澄清。
- 不得把“能力不足”“需要确认”“有风险”“信息不足”“上下文缺失”写成“用户没有要求”。
- 对确认类 tendency,不得输出“缺少确认动作能力”“确认本身不需要行动链”作为拒绝理由;应判断是否能承接 pending action。
- 不得基于 recent_chatmessage 的上一轮内容否定 tendency。
- 不得输出“用户当前并未提出该要求”这类意图抽取判断,除非上下文中存在明确取消、冲突或过期证据。
description:
- 简短概括处理结论。
- 若 ok=true,描述将要推进的行动。
- 若 needConfirm=true,描述应体现该行动需要确认。
- 若 ok=false,描述应体现不推进的真实原因。
输出要求:
- 严格按照 EvaluatorResult 对应结构输出。
- 不要输出结构之外的解释、注释或额外文本。
- 不要输出结构之外的解释、注释、推理过程或额外文本。
""";
@InjectCapability
@@ -104,8 +227,9 @@ public class ActionEvaluator extends AbstractAgentModule.Sub<EvaluatorInput, Lis
cognitionCapability.contextWorkspace().resolve(List.of(
ContextBlock.FocusedDomain.ACTION,
ContextBlock.FocusedDomain.COMMUNICATION,
ContextBlock.FocusedDomain.COGNITION,
ContextBlock.FocusedDomain.MEMORY
ContextBlock.FocusedDomain.PERCEIVE,
ContextBlock.FocusedDomain.MEMORY,
ContextBlock.FocusedDomain.COGNITION
)).encodeToMessage(),
availableMetaActionContext(),
new Message(Message.Character.USER, tendency)
@@ -144,8 +268,8 @@ public class ActionEvaluator extends AbstractAgentModule.Sub<EvaluatorInput, Lis
"available_meta_action",
actionCapability.listAvailableMetaActions().entrySet(),
(block, value) -> {
appendTextElement(document, root, "action_key", value.getKey());
appendTextElement(document, root, "action_value", value.getValue().getDescription());
appendTextElement(document, block, "meta_action_key", value.getKey());
appendTextElement(document, block, "meta_action_description", value.getValue().getDescription());
return Unit.INSTANCE;
}
);
@@ -3,6 +3,7 @@ package work.slhaf.partner.module.action.planner.evaluator.entity;
import lombok.Data;
import work.slhaf.partner.core.action.entity.Schedulable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -31,9 +32,15 @@ public class EvaluatorResult {
}
public Map<Integer, List<String>> getPrimaryActionChainAsMap() {
if (primaryActionChain == null || primaryActionChain.isEmpty()) {
return new LinkedHashMap<>();
}
return primaryActionChain.stream().collect(Collectors.toMap(
ChainElement::getOrder,
ChainElement::getActionKeys,
chainElement -> {
List<String> actionKeys = chainElement.getActionKeys();
return actionKeys == null ? new ArrayList<>() : new ArrayList<>(actionKeys);
},
(oldValue, newValue) -> newValue,
LinkedHashMap::new
));
@@ -48,6 +55,7 @@ public class EvaluatorResult {
@Data
public static class ChainElement {
private Integer order;
private String description;
private List<String> actionKeys;
}
}
@@ -17,19 +17,52 @@ import java.util.List;
public class ActionExtractor extends AbstractAgentModule.Sub<String, Result<ExtractorResult>> implements ActivateModel {
private static final String MODULE_PROMPT = """
你负责从当前输入中提取可能的行动倾向,供后续模块继续评估。
你负责从当前输入中提取“可能值得进入后续行动评估”的行动倾向。
你会收到:
- 一条结构化上下文消息,主要包含 communication 域与 action 域内容;
- 一条结构化上下文消息,主要包含 communication 域、perceive 域与 action 域内容;
- 一条最新输入,作为本轮重点分析对象。
规则:
- communication 域主要用于理解当前会话语境、主题延续与用户意图。
- action 域主要用于判断相关行动是否已经在执行、等待确认,或已被覆盖。
- 只提取“可能值得进入后续评估的行动倾向”,不要输出完整行动计划、执行步骤或工具细节。
- 若某个倾向已经明显处于执行中,且当前输入没有带来新的推进信息、修正信息或条件变化,应避免重复提出。
你的任务:
- 基于当前输入与上下文,提取少量、明确、值得继续进入行动评估的行动倾向;
- 这里只提取“可能需要通过行动推进”的倾向,而不是所有可回应的意图;
- 若当前输入更适合直接交流回应,而不需要额外行动推进,则返回空结果。
基本原则:
- communication 域主要用于理解当前会话语境、话题延续、指代对象与用户真实意图。
- perceive 域可用于理解当前系统状态、环境信息、时间信息等;若当前输入仅是在询问这些已可直接回答的信息,不应因此提取行动倾向。
- action 域主要用于判断相关行动是否已经在执行、等待确认、或已被覆盖。
- 只提取“可能值得进入后续评估的行动倾向”,不要输出完整行动计划、执行步骤、工具细节或命令内容。
- 若某个倾向已经明显处于执行中,且当前输入没有带来新的推进信息、修正信息、确认信息或条件变化,应避免重复提出。
- 若 action 域中存在等待确认的 block,且当前输入与其相关,则必须提取出对应的行动倾向;不要因为其已存在于上下文中而省略。
- 若当前输入没有明显行动倾向,可返回空结果。
复合任务合并规则:
- 若当前输入包含“先...再...最后...”“并且/然后/同时”等多个动作描述,但这些动作共同服务于同一个最终目标,应提取为一个整体行动倾向,而不是拆成多个并列倾向。
- 中间的信息获取、检查、读取、查询步骤如果只是为了支撑最终判断、汇报、生成结果,不应作为独立 tendency 输出。
- 最终的“总结、判断、汇报、说明结果”如果依赖前置行动结果,应并入同一个整体 tendency,而不是作为单独的 communication 类 tendency 输出。
- 只有当多个动作目标彼此独立、可以分别完成且不共享同一个最终目的时,才输出多个 tendency。
什么情况下应提取行动倾向:
- 用户明确要求系统执行某个动作、调用某项能力、访问某类外部信息、操作某个对象或推进某个待办;
- 用户要求修改、继续、取消、确认或补充某个已有待处理行动;
- 用户明确要求系统代其完成某件事,而不是仅作解释、说明或回答;
- 若不进入行动链,则该请求无法被真正推进或完成。
什么情况下不应提取行动倾向:
- 当前输入本质上是一个可直接回答的提问;
- 当前输入只是要求解释、分析、总结、说明、翻译、评价、闲聊或感叹;
- 当前输入只是询问当前上下文、系统状态、已知信息、记忆内容或当前可见内容,而这些内容可直接通过交流给出;
- 当前输入只是一般性的信息查询,但没有明确要求系统通过外部行动补充信息;
- 当前输入只是对已有回复的轻量追问、澄清或自然延续,仍然更适合直接回应;
- 当前输入语义模糊,仅凭“可能有东西可做”不足以进入行动评估;
- 当前输入虽然提到某个对象或任务,但并未形成明确的行动推进意图。
重要限制:
- 不要把“可以通过行动获得更多信息”当成提取理由。
- 不要把“询问信息”自动等同于“要求去执行查询动作”。
- 不要为了提高召回而泛化提取。
- 如果更适合 communication 直接回答,就返回空结果。
- 倾向应简洁明确,表达“要推进什么”,不要写成自然语言执行方案。
输出要求:
- 严格按照 ExtractorResult 对应结构输出。
@@ -44,6 +77,7 @@ public class ActionExtractor extends AbstractAgentModule.Sub<String, Result<Extr
List<Message> messages = List.of(
cognitionCapability.contextWorkspace().resolve(List.of(
ContextBlock.FocusedDomain.COMMUNICATION,
ContextBlock.FocusedDomain.PERCEIVE,
ContextBlock.FocusedDomain.ACTION
)).encodeToMessage(),
new Message(Message.Character.USER, input)
@@ -15,12 +15,14 @@ import work.slhaf.partner.core.action.ActionCapability
import work.slhaf.partner.core.action.entity.Action
import work.slhaf.partner.core.action.entity.Schedulable
import work.slhaf.partner.core.action.entity.SchedulableExecutableAction
import work.slhaf.partner.framework.agent.config.ConfigCenter
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule
import work.slhaf.partner.framework.agent.factory.component.annotation.Init
import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule
import work.slhaf.partner.module.action.executor.ActionExecutor
import java.io.Closeable
import java.nio.file.Path
import java.time.Duration
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
@@ -70,9 +72,10 @@ class ActionScheduler : AbstractAgentModule.Standalone() {
val doneCondition: (Schedulable) -> Boolean = { schedulable ->
if (schedulable is Action) {
schedulable.status == Action.Status.FAILED || schedulable.status == Action.Status.SUCCESS
}
} else {
true
}
}
timeWheel = TimeWheel(listScheduledActions, onTrigger, doneCondition)
}
loadScheduledActions()
@@ -126,6 +129,12 @@ class ActionScheduler : AbstractAgentModule.Standalone() {
val onTrigger: (toTrigger: Set<Schedulable>) -> Unit,
val doneCondition: (schedulable: Schedulable) -> Boolean
) : Closeable {
private val tracePath: Path = ConfigCenter.paths.stateDir
.resolve("trace")
.resolve("time-wheel")
.normalize()
.toAbsolutePath()
private val schedulableGroupByHour = Array<MutableSet<Schedulable>>(24) { mutableSetOf() }
private val wheel = Array<MutableSet<Schedulable>>(60 * 60) { mutableSetOf() }
private var recordHour: Int = -1
@@ -151,15 +160,14 @@ class ActionScheduler : AbstractAgentModule.Standalone() {
logFailedStatus(schedulableData)
return@checkThenExecute
}
log.debug("Action next execution time: {}", parseToZonedDateTime)
log.debug("${schedulableData.uuid} next execution time: {}", parseToZonedDateTime)
val hour = parseToZonedDateTime.hour
schedulableGroupByHour[hour].add(schedulableData)
log.debug("Action scheduled at {}", hour)
if (it.hour == hour) {
val wheelOffset = parseToZonedDateTime.minute * 60 + parseToZonedDateTime.second
wheel[wheelOffset].add(schedulableData)
state.value = WheelState.ACTIVE
log.debug("Action scheduled at wheel offset {}", wheelOffset)
log.debug("${schedulableData.uuid} scheduled at wheel offset {}", wheelOffset)
}
}
}
@@ -201,8 +209,13 @@ class ActionScheduler : AbstractAgentModule.Standalone() {
val shouldBreak: Boolean
)
fun collectToTrigger(tick: Int, previousTick: Int, triggerHour: Int): Set<Schedulable>? {
if (tick > previousTick) {
fun collectToTrigger(
tick: Int,
previousTick: Int,
triggerHour: Int,
includeRemainingHour: Boolean = false
): Set<Schedulable>? {
if (tick >= previousTick) {
val toTrigger = mutableSetOf<Schedulable>()
for (i in previousTick..tick) {
val bucket = wheel[i]
@@ -213,6 +226,13 @@ class ActionScheduler : AbstractAgentModule.Standalone() {
bucket.clear() // 避免重复触发
}
}
if (includeRemainingHour) {
val remainingHourBucket = schedulableGroupByHour[triggerHour]
if (remainingHourBucket.isNotEmpty()) {
toTrigger.addAll(remainingHourBucket.filter { it.enabled })
remainingHourBucket.clear()
}
}
return toTrigger
}
return null
@@ -261,7 +281,12 @@ class ActionScheduler : AbstractAgentModule.Standalone() {
checkThenExecute(false) {
if (it.hour != launchingHour) {
shouldBreak = true
toTrigger = collectToTrigger(wheel.lastIndex, previousTick, launchingHour)
toTrigger = collectToTrigger(
wheel.lastIndex,
previousTick,
launchingHour,
includeRemainingHour = true
)
log.debug(
"Hour changed, previousTick: {}, tick: {}, toTriggerSize: {}",
previousTick,
@@ -270,8 +295,13 @@ class ActionScheduler : AbstractAgentModule.Standalone() {
)
return@checkThenExecute
}
toTrigger = collectToTrigger(tick, previousTick, launchingHour)
if (tick >= wheel.lastIndex || schedulableGroupByHour[launchingHour].isEmpty()) {
toTrigger = collectToTrigger(
tick,
previousTick,
launchingHour,
includeRemainingHour = tick >= wheel.lastIndex
)
if (tick >= wheel.lastIndex) {
state.value = WheelState.SLEEPING
shouldBreak = true
}
@@ -0,0 +1,50 @@
package work.slhaf.partner.module
import org.w3c.dom.Document
import org.w3c.dom.Element
import work.slhaf.partner.common.base.Block
import work.slhaf.partner.core.cognition.CommunicationBlockContent
import work.slhaf.partner.core.cognition.ContextBlock
import work.slhaf.partner.framework.agent.model.pojo.Message
abstract class TaskBlock @JvmOverloads constructor(
blockName: String = "task_input"
) : Block(blockName) {
fun encodeToMessage(): Message {
return Message(Message.Character.USER, encodeToXmlString())
}
}
abstract class StateHintContent protected constructor(
source: String,
val stateMsg: String
) : CommunicationBlockContent("new_state", source, Urgency.NORMAL, Projection.SUPPLY) {
override fun fillXml(document: Document, root: Element) {
appendTextElement(document, root, "state_msg", stateMsg)
appendChildElement(document, root, "state_content") {
fillStateContent(document, this@appendChildElement)
}
}
abstract fun fillStateContent(document: Document, stateElement: Element)
private fun toContextBlock(): ContextBlock {
return ContextBlock(
blockContent = this,
focusedOn = setOf(ContextBlock.FocusedDomain.COGNITION),
replaceFadeFactor = 60.0,
timeFadeFactor = 100.0,
activateFactor = 0.0
)
}
companion object {
@JvmStatic
fun createBlock(block: StateHintContent): ContextBlock {
return block.toContextBlock()
}
}
}
@@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import work.slhaf.partner.core.cognition.*;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule;
@@ -29,7 +30,6 @@ import java.util.stream.Collectors;
@Slf4j
public class CommunicationProducer extends AbstractAgentModule.Running<PartnerRunningFlowContext> implements ActivateModel {
private static final String INTERRUPTED_MARKER = " [response interrupted due to internal exception]";
private static final String NO_REPLY_MARKER = "NO_REPLY";
private static final String AGENT_MARKER = "[[AGENT]: self]";
private static final String NOT_REPLIED_PREFIX = "[NOT_REPLIED]";
@@ -37,6 +37,10 @@ public class CommunicationProducer extends AbstractAgentModule.Running<PartnerRu
private static final String MODULE_PROMPT = """
你当前正在承担 Partner 的对外交流职责。你需要基于系统此刻的上下文状态、保留的对话轨迹以及最新输入,生成自然、贴合当前情境、并与系统整体状态一致的交流结果。
- 不要承诺、暗示或宣称自己即将执行某个动作;例如不要说“稍等”“我去查看”“正在为你获取”“马上执行”。
- 只有当 <context> 的 action 域明确存在正在执行、等待确认、刚完成、或正在评估的行动状态时,才可以提及行动链进展。
- 若最新输入看起来像行动请求,但 <context> 中没有对应的 action 状态信号,不要自行补出“将会执行”的过渡话术;应直接给出可交流的回应,或说明当前没有可确认的执行状态。
- 若收到“action finished”一类内部触发输入,只能依据 <context> 中真实存在的行动完成状态回复;若没有可验证的完成结果,应使用 NO_REPLY 或说明没有可确认结果,不得编造结果。
你接下来收到的消息,将按照出现顺序,固定分为三个区段:
1. system message 是 Head,用于说明整个输入结构与输出要求,即本条消息。
@@ -52,6 +56,8 @@ public class CommunicationProducer extends AbstractAgentModule.Running<PartnerRu
输出契约:
- 默认情况下,直接输出要发送给用户的最终回复正文,不要添加额外标签、解释或前后缀。
- 对话轨迹中的身份行、状态行、调试行和传输边界行都属于系统内部序列化标记,只用于理解上下文,不属于可复用正文。
- 不得在输出中复现、仿写或携带任何历史序列化标记、调试前缀或传输边界标记;即使用户正在讨论这些前缀,也只能用自然语言确认或说明。
- 若当前情境下不应直接向用户发出回复,但仍需要留下本轮交流结果供系统后续保留在交流轨迹中,则输出以 NO_REPLY 开头。
- 使用 NO_REPLY 时,格式为:
@@ -87,9 +93,8 @@ public class CommunicationProducer extends AbstractAgentModule.Running<PartnerRu
}
private void executeChat(PartnerRunningFlowContext runningFlowContext) {
StreamChatMessageConsumer consumer = ReplyDispatcher.INSTANCE.createConsumer(runningFlowContext.getTarget());
this.streamChat(buildChatMessages(runningFlowContext), consumer)
.onFailure(exception -> consumer.onDelta(INTERRUPTED_MARKER));
StreamChatMessageConsumer consumer = ReplyDispatcher.INSTANCE.createConsumer(runningFlowContext.getTarget(), runningFlowContext.getResopnseChannel());
this.streamChat(buildChatMessages(runningFlowContext), consumer);
updateChatMessages(runningFlowContext, consumer.collectResponse());
cognitionCapability.refreshRecentChatMessagesContext();
}
@@ -171,7 +176,7 @@ public class CommunicationProducer extends AbstractAgentModule.Running<PartnerRu
Element root = document.createElement("input");
document.appendChild(root);
document.appendChild(document.importNode(runningFlowContext.encodeInputsBlock().encodeToXml(), true));
root.appendChild(importExternalNode(document, runningFlowContext.encodeInputsBlock().encodeToXml()));
appendTextElement(document, root, "source", runningFlowContext.getSource());
for (Map.Entry<String, String> entry : runningFlowContext.getAdditionalUserInfo().entrySet()) {
appendTextElement(document, root, sanitizeTagName(entry.getKey()), entry.getValue());
@@ -242,11 +247,25 @@ public class CommunicationProducer extends AbstractAgentModule.Running<PartnerRu
inputRoot.appendChild(groupElement);
for (CommunicationBlockContent block : entry.getValue()) {
Element blockElement = block.encodeToXml();
groupElement.appendChild(document.importNode(blockElement, true));
groupElement.appendChild(importExternalNode(document, blockElement));
}
}
}
private Node importExternalNode(Document targetDocument, Node externalNode) {
Node nodeToImport = externalNode;
if (externalNode != null && externalNode.getNodeType() == Node.DOCUMENT_NODE) {
nodeToImport = ((Document) externalNode).getDocumentElement();
}
if (nodeToImport == null) {
throw new IllegalArgumentException("待导入节点不能为空");
}
if (nodeToImport.getOwnerDocument() == targetDocument) {
return nodeToImport.cloneNode(true);
}
return targetDocument.importNode(nodeToImport, true);
}
private String sanitizeTagName(String rawTagName) {
if (rawTagName == null || rawTagName.isBlank()) {
return "meta";
@@ -41,7 +41,8 @@ public class DialogRolling extends AbstractAgentModule.Running<PartnerRunningFlo
private static final String AUTO_UPDATE_CRON = "0/10 * * * * ?";
private static final long UPDATE_TRIGGER_INTERVAL = 60 * 60 * 1000;
private static final int CONTEXT_RETAIN_DIVISOR = 6;
private static final int DIALOG_ROLLING_TRIGGER_LIMIT = 36;
private static final int FULL_ROLLING_RETAIN_DIVISOR = Integer.MAX_VALUE;
private static final int DIALOG_ROLLING_TRIGGER_LIMIT = 72;
private final AtomicBoolean rolling = new AtomicBoolean(false);
@@ -119,7 +120,8 @@ public class DialogRolling extends AbstractAgentModule.Running<PartnerRunningFlo
return;
}
RollingResult result = buildRollingResult(chatIncrement, fullChatSnapshot.size(), CONTEXT_RETAIN_DIVISOR);
int retainDivisor = refreshMemoryId ? FULL_ROLLING_RETAIN_DIVISOR : CONTEXT_RETAIN_DIVISOR;
RollingResult result = buildRollingResult(chatIncrement, fullChatSnapshot.size(), retainDivisor);
applyRolling(result);
afterRollingRegistry.trigger(result);
@@ -1,84 +1,27 @@
package work.slhaf.partner.module.communication
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import work.slhaf.partner.api.InteractionEvent.EventStatus
import work.slhaf.partner.api.ReplyEvent
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException
import work.slhaf.partner.framework.agent.exception.ExceptionReporterHandler
import work.slhaf.partner.framework.agent.interaction.AgentRuntime
import work.slhaf.partner.framework.agent.interaction.data.InteractionEvent.EventStatus
import work.slhaf.partner.framework.agent.interaction.data.Reply
import work.slhaf.partner.framework.agent.model.StreamChatMessageConsumer
import kotlin.time.Duration.Companion.milliseconds
object ReplyDispatcher {
private const val AGGREGATE_WINDOW_MILLIS = 100L
private const val NO_REPLY_MARKER = "NO_REPLY"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val collectorChannel = Channel<ReplyChunk>(Channel.UNLIMITED)
init {
scope.launch {
var pendingChunk: ReplyChunk? = null
while (true) {
val firstChunk = pendingChunk ?: collectorChannel.receiveCatching().getOrNull() ?: break
pendingChunk = null
val builder = StringBuilder(firstChunk.delta)
while (true) {
val nextChunk = withTimeoutOrNull(AGGREGATE_WINDOW_MILLIS.milliseconds) {
collectorChannel.receiveCatching()
} ?: break
if (nextChunk.isClosed) {
flush(builder.toString(), firstChunk.target)
return@launch
}
val chunk = nextChunk.getOrNull() ?: break
if (chunk.target == firstChunk.target) {
builder.append(chunk.delta)
} else {
pendingChunk = chunk
break
}
}
flush(builder.toString(), firstChunk.target)
}
}
}
/**
* flush 将推送至 AgentRuntime 的默认通道。
*/
private fun flush(content: String, target: String) {
if (content.isEmpty()) {
return
}
val event = Reply(
status = EventStatus.RUNNING,
fun createConsumer(target: String, responseChannel: String?): StreamChatMessageConsumer = ReplyConsumer(
target = target,
content = content,
mode = Reply.ContentMode.APPEND,
done = false
responseChannel = responseChannel
)
AgentRuntime.response(event)
}
fun createConsumer(target: String): StreamChatMessageConsumer = ReplyConsumer(
collectorChannel = collectorChannel,
target = target,
)
private data class ReplyChunk(
val delta: String,
val target: String,
)
private class ReplyConsumer(
private val collectorChannel: Channel<ReplyChunk>,
private val target: String,
) : StreamChatMessageConsumer() {
private val responseChannel: String?
) : StreamChatMessageConsumer {
private enum class VisibilityState {
UNDECIDED,
@@ -88,22 +31,54 @@ object ReplyDispatcher {
private val rawResponse = StringBuilder()
private val undecidedBuffer = StringBuilder()
private val visibleBuffer = StringBuilder()
private val lock = Any()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var flushJob: Job? = null
private var visibilityState = VisibilityState.UNDECIDED
private var terminalEmitted = false
override fun onDelta(delta: String) {
synchronized(lock) {
rawResponse.append(delta)
routeDelta(delta)
}
}
override fun onComplete() {
synchronized(lock) {
finalizeUndecidedBuffer()
flushVisibleBufferLocked()
dispatchTerminalLocked(EventStatus.DONE)
shutdownScopeLocked()
}
}
override fun onError(exception: AgentRuntimeException) {
synchronized(lock) {
ExceptionReporterHandler.report(exception)
rawResponse.append(INTERRUPTED_MARKER)
routeDelta(INTERRUPTED_MARKER)
finalizeUndecidedBuffer()
flushVisibleBufferLocked()
dispatchTerminalLocked(EventStatus.ERROR)
shutdownScopeLocked()
}
}
override fun collectResponse(): String {
synchronized(lock) {
finalizeUndecidedBuffer()
flushVisibleBufferLocked()
return rawResponse.toString()
}
}
private fun routeDelta(delta: String) {
when (visibilityState) {
VisibilityState.NO_REPLY -> return
VisibilityState.VISIBLE -> flushVisible(delta)
VisibilityState.VISIBLE -> appendVisibleLocked(delta)
VisibilityState.UNDECIDED -> {
undecidedBuffer.append(delta)
resolveVisibility()
@@ -150,17 +125,79 @@ object ReplyDispatcher {
private fun revealBufferedContent() {
visibilityState = VisibilityState.VISIBLE
if (undecidedBuffer.isNotEmpty()) {
flushVisible(undecidedBuffer.toString())
appendVisibleLocked(undecidedBuffer.toString())
undecidedBuffer.setLength(0)
}
}
private fun flushVisible(delta: String) {
collectorChannel.trySend(ReplyChunk(delta, target)).isSuccess
private fun appendVisibleLocked(delta: String) {
if (delta.isEmpty()) {
return
}
visibleBuffer.append(delta)
scheduleFlushLocked()
}
override fun consumeDelta(delta: String?) {
private fun scheduleFlushLocked() {
flushJob?.cancel()
flushJob = scope.launch {
delay(AGGREGATE_WINDOW_MILLIS.milliseconds)
synchronized(lock) {
flushVisibleBufferLocked()
}
}
}
private fun flushVisibleBufferLocked() {
flushJob?.cancel()
flushJob = null
if (visibleBuffer.isEmpty()) {
return
}
val content = visibleBuffer.toString()
visibleBuffer.setLength(0)
dispatchLocked(
ReplyEvent(
status = EventStatus.RUNNING,
target = target,
content = content,
mode = ReplyEvent.ContentMode.APPEND
)
)
}
private fun dispatchTerminalLocked(status: EventStatus) {
if (terminalEmitted) {
return
}
terminalEmitted = true
dispatchLocked(
ReplyEvent(
status = status,
target = target,
content = "",
mode = ReplyEvent.ContentMode.APPEND
)
)
}
private fun dispatchLocked(event: ReplyEvent) {
if (responseChannel == null) {
AgentRuntime.response(event)
} else {
AgentRuntime.response(event, responseChannel)
}
}
private fun shutdownScopeLocked() {
flushJob?.cancel()
flushJob = null
scope.cancel()
}
private companion object {
private const val AGGREGATE_WINDOW_MILLIS = 100L
private const val INTERRUPTED_MARKER = "\n[response interrupted due to internal exception]"
private const val NO_REPLY_MARKER = "NO_REPLY"
}
}
@@ -65,7 +65,7 @@ public class MessageSummarizer extends AbstractAgentModule.Sub<List<Message>, Re
return new TaskBlock() {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
document.appendChild(document.importNode(cognitionCapability.messageNotesElement(), true));
root.appendChild(document.importNode(cognitionCapability.messageNotesElement(), true));
appendListElement(document, root, "chat_messages", "message", messages, (element, message) -> {
element.setAttribute("role", message.roleValue());
element.setTextContent(message.getContent());
@@ -96,6 +96,9 @@ public class MemoryRuntime extends AbstractAgentModule.Standalone implements Sta
}
public String fixTopicPath(String topicPath) {
if (topicPath == null || topicPath.isBlank()) {
return "";
}
String[] parts = topicPath.split("->");
List<String> cleanedParts = new ArrayList<>();
for (String part : parts) {
@@ -0,0 +1,15 @@
package work.slhaf.partner.module.memory.selector;
import lombok.AllArgsConstructor;
import lombok.Data;
import work.slhaf.partner.framework.agent.interaction.flow.RunningFlowContext;
import java.time.LocalDateTime;
import java.util.List;
@Data
@AllArgsConstructor
public class MemoryInputEntry {
private LocalDateTime receivedDateTime;
private List<RunningFlowContext.InputEntry> inputs;
}
@@ -5,6 +5,8 @@ import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import work.slhaf.partner.core.action.ActionCapability;
import work.slhaf.partner.core.action.ActionCore;
import work.slhaf.partner.core.cognition.BlockContent;
import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.ContextBlock;
@@ -23,7 +25,11 @@ import work.slhaf.partner.module.memory.selector.extractor.entity.ExtractorResul
import work.slhaf.partner.runtime.PartnerRunningFlowContext;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Slf4j
public class MemorySelector extends AbstractAgentModule.Running<PartnerRunningFlowContext> {
@@ -33,6 +39,7 @@ public class MemorySelector extends AbstractAgentModule.Running<PartnerRunningFl
@InjectCapability
private CognitionCapability cognitionCapability;
private final AtomicBoolean memoryCalling = new AtomicBoolean(false);
@InjectModule
private MemoryRuntime memoryRuntime;
@@ -40,24 +47,125 @@ public class MemorySelector extends AbstractAgentModule.Running<PartnerRunningFl
private MemoryRecallEvaluator memoryRecallEvaluator;
@InjectModule
private MemoryRecallCueExtractor memoryRecallCueExtractor;
private final Lock inputsLock = new ReentrantLock();
private final List<MemoryInputEntry> collectedInputs = new ArrayList<>();
@InjectCapability
private ActionCapability actionCapability;
@Override
protected void doExecute(@NotNull PartnerRunningFlowContext runningFlowContext) {
List<RunningFlowContext.InputEntry> snapshotInputs = List.copyOf(runningFlowContext.getInputs());
collectInputs(runningFlowContext);
tryStartMemoryRecallWorker();
}
private void collectInputs(PartnerRunningFlowContext runningFlowContext) {
inputsLock.lock();
try {
collectedInputs.add(new MemoryInputEntry(
runningFlowContext.getFirstInputDateTime(),
List.copyOf(runningFlowContext.getInputs())
));
} finally {
inputsLock.unlock();
}
}
private void tryStartMemoryRecallWorker() {
if (!memoryCalling.compareAndSet(false, true)) {
return;
}
actionCapability.getExecutor(ActionCore.ExecutorType.VIRTUAL).execute(() -> {
try {
drainMemoryRecall();
} finally {
memoryCalling.set(false);
if (hasCollectedInputs()) {
tryStartMemoryRecallWorker();
}
}
});
}
private void drainMemoryRecall() {
while (true) {
List<MemoryInputEntry> snapshotInputs = drainCollectedInputs();
if (snapshotInputs.isEmpty()) {
return;
}
try {
recallMemory(snapshotInputs);
} catch (Exception e) {
log.error("[MemorySelector] 记忆召回任务执行失败", e);
}
}
}
private void recallMemory(List<MemoryInputEntry> memoryInputEntries) {
ExtractorInput input = new ExtractorInput(
snapshotInputs,
memoryRuntime.getTopicTree(),
runningFlowContext.getFirstInputDateTime().toLocalDate()
memoryInputEntries,
memoryRuntime.getTopicTree()
);
ExtractorResult extractorResult = memoryRecallCueExtractor.execute(input);
if (extractorResult.getMatches().isEmpty()) {
return;
}
List<ActivatedMemorySlice> activatedSlices = selectAndEvaluateMemory(snapshotInputs, extractorResult);
List<ActivatedMemorySlice> activatedSlices = selectAndEvaluateMemory(flattenInputs(memoryInputEntries), extractorResult);
updateMemoryContext(activatedSlices);
}
private List<MemoryInputEntry> drainCollectedInputs() {
inputsLock.lock();
try {
if (collectedInputs.isEmpty()) {
return List.of();
}
List<MemoryInputEntry> snapshot = new ArrayList<>(collectedInputs);
collectedInputs.clear();
snapshot.sort(Comparator.comparing(MemoryInputEntry::getReceivedDateTime));
return snapshot;
} finally {
inputsLock.unlock();
}
}
private boolean hasCollectedInputs() {
inputsLock.lock();
try {
return !collectedInputs.isEmpty();
} finally {
inputsLock.unlock();
}
}
private List<RunningFlowContext.InputEntry> flattenInputs(List<MemoryInputEntry> memoryInputEntries) {
if (memoryInputEntries.isEmpty()) {
return List.of();
}
long firstEpochMillis = memoryInputEntries.stream()
.map(MemoryInputEntry::getReceivedDateTime)
.mapToLong(this::toEpochMillis)
.min()
.orElseThrow();
return memoryInputEntries.stream()
.flatMap(entry -> {
long entryEpochMillis = toEpochMillis(entry.getReceivedDateTime());
return entry.getInputs().stream()
.map(input -> new RunningFlowContext.InputEntry(
entryEpochMillis + input.getOffsetMillis() - firstEpochMillis,
input.getContent()
));
})
.sorted(Comparator.comparingLong(RunningFlowContext.InputEntry::getOffsetMillis))
.toList();
}
private long toEpochMillis(java.time.LocalDateTime dateTime) {
return dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
private void updateMemoryContext(List<ActivatedMemorySlice> activatedSlices) {
cognitionCapability.contextWorkspace().register(new ContextBlock(
buildMemoryFullBlock(activatedSlices),
@@ -155,6 +263,9 @@ public class MemorySelector extends AbstractAgentModule.Running<PartnerRunningFl
private void setMemoryCandidates(LinkedHashMap<String, ActivatedMemorySlice> candidates, List<ExtractorResult.ExtractorMatchData> matches) {
for (ExtractorResult.ExtractorMatchData match : matches) {
if (match == null || match.getType() == null || match.getText() == null || match.getText().isBlank()) {
continue;
}
try {
List<ActivatedMemorySlice> recalledSlices = switch (match.getType()) {
case ExtractorResult.ExtractorMatchData.Constant.TOPIC ->
@@ -18,6 +18,8 @@ import work.slhaf.partner.module.memory.selector.extractor.entity.ExtractorInput
import work.slhaf.partner.module.memory.selector.extractor.entity.ExtractorResult;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class MemoryRecallCueExtractor extends AbstractAgentModule.Sub<ExtractorInput, ExtractorResult> implements ActivateModel {
@@ -28,12 +30,11 @@ public class MemoryRecallCueExtractor extends AbstractAgentModule.Sub<ExtractorI
你会收到:
- 一条结构化上下文消息,其中包含当前活跃的 communication 域与 memory 域内容;
- 一条任务消息,其中包含:
- new_inputs:一组按时间顺序累积的新输入,每条输入附带 interval-to-first;
- current_date:当前日期;
- memory_input_entries:一组按接收时间排序的新输入批次;每个批次包含 received_date_time 与该批次内按时间顺序排列的 inputs;
- memory_topic_tree:当前可用的记忆主题树结构。
你的任务:
- 基于 new_inputs、当前语境与已有记忆主题树,提取本次记忆召回最值得尝试的匹配项;
- 基于 memory_input_entries、当前语境与已有记忆主题树,提取本次记忆召回最值得尝试的匹配项;
- 匹配项只允许有两类:topic 或 date;
- topic 用于表示应优先检索的记忆主题路径;
- date 用于表示应优先检索的具体日期;
@@ -41,7 +42,7 @@ public class MemoryRecallCueExtractor extends AbstractAgentModule.Sub<ExtractorI
提取原则:
- 你的目标是提取“可用于后续召回”的线索,而不是复述输入内容本身。
- new_inputs 应整体理解,不要只抓最后一句;如果多条输入共同收敛到同一记忆方向,应提取更稳定的主题线索。
- memory_input_entries 应整体理解,不要只抓最后一个批次或最后一句;如果多个批次共同收敛到同一记忆方向,应提取更稳定的主题线索。
- communication 域用于判断当前输入是否在承接近期某段对话、某个旧话题或某个已出现过的指代对象。
- memory 域用于辅助判断当前输入与哪些已激活记忆方向明显相关;只有在这种相关性明确时才使用,不要机械复述 memory 域内容。
- memory_topic_tree 是 topic 提取的主要参照;topic 应尽量贴近主题树中已有的层级与命名,不要随意发明与主题树无关的新路径。
@@ -59,8 +60,8 @@ public class MemoryRecallCueExtractor extends AbstractAgentModule.Sub<ExtractorI
关于 date:
- date 表示一个明确的记忆日期。
- date 的 text 必须是可被 Java LocalDate.parse 正常解析的日期文本,即 yyyy-MM-dd。
- 只有在输入中存在明确日期,或结合 current_date 后可以稳定推断出具体某一天时,才输出 date。
- 像“今天”“昨天”“前天”“上周六”这类表达,只有在能够稳定落到某个具体日期时才可输出。
- 只有在输入中存在明确日期,或结合相关输入批次的 received_date_time 后可以稳定推断出具体某一天时,才输出 date。
- 像“今天”“昨天”“前天”“上周六”这类表达,只有在能够根据对应批次 received_date_time 稳定落到某个具体日期时才可输出。
- 对于“最近”“前几天”“那段时间”“之前”“上次”这类无法稳定定位到某一天的表达,不要输出 date。
何时应提取 topic:
@@ -71,7 +72,7 @@ public class MemoryRecallCueExtractor extends AbstractAgentModule.Sub<ExtractorI
何时应提取 date:
- 当前输入明确提到了某个具体日期;
- 当前输入使用相对日期表达,但结合 current_date 可以稳定还原到具体某一天;
- 当前输入使用相对日期表达,但结合对应批次的 received_date_time 可以稳定还原到具体某一天;
- 当前输入中的回忆目标明显依赖某个特定日期,且该日期能够被明确确定。
何时不应轻易输出:
@@ -134,15 +135,20 @@ public class MemoryRecallCueExtractor extends AbstractAgentModule.Sub<ExtractorI
return new TaskBlock() {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendChildElement(document, root, "new_inputs", (inputsElement) -> {
appendListElement(document, inputsElement, "inputs", "input", input.getInputs(), (inputElement, entry) -> {
appendChildElement(document, root, "memory_input_entries", (entriesElement) -> {
appendRepeatedElements(document, entriesElement, "memory_input_entry", input.getMemoryInputEntries().stream()
.sorted(Comparator.comparing(work.slhaf.partner.module.memory.selector.MemoryInputEntry::getReceivedDateTime))
.toList(), (entryElement, memoryInputEntry) -> {
appendTextElement(document, entryElement, "received_date_time", memoryInputEntry.getReceivedDateTime().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
appendListElement(document, entryElement, "inputs", "input", memoryInputEntry.getInputs(), (inputElement, entry) -> {
inputElement.setAttribute("interval-to-first", String.valueOf(entry.getOffsetMillis()));
inputElement.setTextContent(entry.getContent());
return Unit.INSTANCE;
});
return Unit.INSTANCE;
});
appendTextElement(document, root, "current_date", input.getDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
return Unit.INSTANCE;
});
appendTextElement(document, root, "memory_topic_tree", input.getTopic_tree());
}
}.encodeToMessage();
@@ -155,17 +161,40 @@ public class MemoryRecallCueExtractor extends AbstractAgentModule.Sub<ExtractorI
}
private ExtractorResult fix(ExtractorResult extractorResult) {
extractorResult.getMatches().forEach(m -> {
if (m.getType().equals(ExtractorResult.ExtractorMatchData.Constant.DATE)) {
return;
ExtractorResult safeResult = extractorResult == null ? new ExtractorResult() : extractorResult;
List<ExtractorResult.ExtractorMatchData> rawMatches = safeResult.getMatches();
if (rawMatches == null || rawMatches.isEmpty()) {
safeResult.setMatches(List.of());
return safeResult;
}
m.setText(memoryRuntime.fixTopicPath(m.getText()));
});
if (extractorResult.getMatches().isEmpty()) {
return extractorResult;
List<ExtractorResult.ExtractorMatchData> normalizedMatches = new ArrayList<>();
for (ExtractorResult.ExtractorMatchData match : rawMatches) {
if (match == null) {
continue;
}
extractorResult.getMatches().removeIf(m -> m.getText().split("->")[0].isEmpty());
return extractorResult;
String type = match.getType();
String text = match.getText();
if (text == null || text.isBlank()) {
continue;
}
if (ExtractorResult.ExtractorMatchData.Constant.TOPIC.equals(type)) {
text = memoryRuntime.fixTopicPath(text);
if (text.isBlank() || text.split("->")[0].isEmpty()) {
continue;
}
match.setText(text);
normalizedMatches.add(match);
continue;
}
if (ExtractorResult.ExtractorMatchData.Constant.DATE.equals(type)) {
normalizedMatches.add(match);
}
}
safeResult.setMatches(normalizedMatches);
return safeResult;
}
@Override
@@ -2,15 +2,13 @@ package work.slhaf.partner.module.memory.selector.extractor.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import work.slhaf.partner.framework.agent.interaction.flow.RunningFlowContext;
import work.slhaf.partner.module.memory.selector.MemoryInputEntry;
import java.time.LocalDate;
import java.util.List;
@Data
@AllArgsConstructor
public class ExtractorInput {
private List<RunningFlowContext.InputEntry> inputs;
private List<MemoryInputEntry> memoryInputEntries;
private String topic_tree;
private LocalDate date;
}
@@ -0,0 +1,18 @@
package work.slhaf.partner.runtime;
import work.slhaf.partner.common.vector.VectorClientRegistry;
import work.slhaf.partner.framework.agent.Agent;
import work.slhaf.partner.runtime.gateway.WebSocketGatewayRegistration;
public final class PartnerAgentBootstrap extends Agent.AgentBootstrap {
public PartnerAgentBootstrap(Agent.AgentApp agentApp) {
super(agentApp);
}
@Override
protected void bootstrap() {
addGatewayRegistration(new WebSocketGatewayRegistration());
addConfigurable(new VectorClientRegistry());
}
}
@@ -8,8 +8,9 @@ import work.slhaf.partner.framework.agent.interaction.flow.RunningFlowContext
class PartnerRunningFlowContext private constructor(
override val source: String,
inputs: List<InputEntry>,
firstInputEpochMillis: Long
) : RunningFlowContext(inputs, firstInputEpochMillis) {
firstInputEpochMillis: Long,
responseChannel: String? = null
) : RunningFlowContext(inputs, firstInputEpochMillis, responseChannel) {
companion object {
@@ -32,12 +33,18 @@ class PartnerRunningFlowContext private constructor(
@JvmStatic
@JvmOverloads
fun fromUser(userId: String, input: String, receivedAtMillis: Long = System.currentTimeMillis()) =
fun fromUser(
userId: String,
input: String,
receivedAtMillis: Long = System.currentTimeMillis(),
responseChannel: String? = null
) =
PartnerRunningFlowContext(
SourceTag.buildUserSource(userId),
listOf(InputEntry(0L, input)),
receivedAtMillis
)
receivedAtMillis,
responseChannel
).apply { this.target = userId }
@JvmStatic
@JvmOverloads
@@ -45,7 +52,8 @@ class PartnerRunningFlowContext private constructor(
PartnerRunningFlowContext(
SourceTag.buildAgentSource(),
listOf(InputEntry(0L, input)),
receivedAtMillis
receivedAtMillis,
null
).apply {
putUserInfo(InfoKeys.PLATFORM, SOURCE_SELF_PLATFORM)
putUserInfo(InfoKeys.NICKNAME, SOURCE_SELF_NICKNAME)
@@ -56,7 +64,8 @@ class PartnerRunningFlowContext private constructor(
return PartnerRunningFlowContext(
source = source,
inputs = inputs,
firstInputEpochMillis = System.currentTimeMillis()
firstInputEpochMillis = System.currentTimeMillis(),
resopnseChannel
)
}
@@ -1,5 +1,6 @@
package work.slhaf.partner.runtime.gateway;
import com.alibaba.fastjson2.JSONException;
import com.alibaba.fastjson2.JSONObject;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
@@ -8,11 +9,9 @@ import org.java_websocket.framing.Framedata;
import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.api.InputData;
import work.slhaf.partner.api.InteractionEvent;
import work.slhaf.partner.framework.agent.interaction.AgentGateway;
import work.slhaf.partner.framework.agent.interaction.AgentGatewayRegistration;
import work.slhaf.partner.framework.agent.interaction.AgentRuntime;
import work.slhaf.partner.framework.agent.interaction.data.InputData;
import work.slhaf.partner.framework.agent.interaction.data.InteractionEvent;
import work.slhaf.partner.runtime.PartnerRunningFlowContext;
import java.net.InetSocketAddress;
@@ -34,11 +33,13 @@ public class WebSocketGateway extends WebSocketServer implements AgentGateway<In
// 记录最后一次收到Pong的时间
private final ConcurrentHashMap<WebSocket, Long> lastPongTimes = new ConcurrentHashMap<>();
public WebSocketGateway(int port, long heartbeatInterval) {
super(new InetSocketAddress(port));
public WebSocketGateway(int port, @NotNull String hostname, long heartbeatInterval) {
super(new InetSocketAddress(hostname, port));
this.heartbeatInterval = heartbeatInterval;
this.setReuseAddr(true);
this.executor = Executors.newSingleThreadExecutor();
log.info("WebSocketGateway will start on {}: {}", hostname, port);
}
public void launch() {
@@ -47,12 +48,11 @@ public class WebSocketGateway extends WebSocketServer implements AgentGateway<In
}
this.start();
startHeartbeatThread();
AgentRuntime.INSTANCE.registerResponseChannel(getChannelName(), this);
}
@Override
public PartnerRunningFlowContext parseRunningFlowContext(InputData inputData) {
PartnerRunningFlowContext context = PartnerRunningFlowContext.fromUser(inputData.getSource(), inputData.getContent());
PartnerRunningFlowContext context = PartnerRunningFlowContext.fromUser(inputData.getSource(), inputData.getContent(),System.currentTimeMillis(), getChannelName());
inputData.getMeta().forEach(context::putUserInfo);
return context;
}
@@ -119,14 +119,18 @@ public class WebSocketGateway extends WebSocketServer implements AgentGateway<In
@Override
public void onMessage(WebSocket webSocket, String s) {
try {
InputData inputData = JSONObject.parseObject(s, InputData.class);
userSessions.put(inputData.getSource(), webSocket); // 注册连接
receive(inputData);
} catch (JSONException ignored) {
log.warn("Invalid input format: {}", s);
}
}
@Override
public void onError(WebSocket webSocket, Exception e) {
log.error(e.getLocalizedMessage());
log.error("WebSocketGateway error", e);
}
@Override
@@ -134,11 +138,6 @@ public class WebSocketGateway extends WebSocketServer implements AgentGateway<In
log.info("WebSocketServer 已启动...");
}
@Override
public AgentGatewayRegistration registration() {
return WebSocketGatewayRegistration.INSTANCE;
}
@Override
@NotNull
public String getChannelName() {
@@ -3,16 +3,17 @@ package work.slhaf.partner.runtime.gateway
import work.slhaf.partner.framework.agent.interaction.AgentGateway
import work.slhaf.partner.framework.agent.interaction.AgentGatewayRegistration
object WebSocketGatewayRegistration : AgentGatewayRegistration {
class WebSocketGatewayRegistration : AgentGatewayRegistration {
override val channelName: String = "websocket_channel"
override fun create(params: Map<String, String>): AgentGateway<*, *> {
val port = params["port"]?.toIntOrNull() ?: 29600
val heartbeatInterval = params["heartbeat_interval"]?.toLongOrNull() ?: 10_000L
val hostname = params["hostname"] ?: "127.0.0.1"
require(port > 0) { "port must be greater than 0" }
require(heartbeatInterval > 0) { "heartbeat_interval must be greater than 0" }
return WebSocketGateway(port, heartbeatInterval)
return WebSocketGateway(port, hostname, heartbeatInterval)
}
override fun shutdown(instance: AgentGateway<*, *>) {
+22 -16
View File
@@ -1,26 +1,32 @@
<configuration>
<!-- 文件输出(保持你的原配置) -->
<appender name="ROLLING_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>./data/log/partner.log</file>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>./data/log/partner.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
</appender>
<property name="PARTNER_HOME" value="${PARTNER_HOME:-${user.home}/.partner}"/>
<property name="PARTNER_LOG_DIR" value="${PARTNER_HOME}/state/trace/log"/>
<property name="LOG_LEVEL" value="${PARTNER_LOG_LEVEL:-${partner.log.level:-INFO}}"/>
<!-- 新增控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- 同时输出到文件和控制台 -->
<root level="DEBUG">
<appender-ref ref="ROLLING_FILE"/>
<appender-ref ref="CONSOLE"/> <!-- 关键:添加这一行 -->
<appender name="PARTNER_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${PARTNER_LOG_DIR}/partner-core.log</file>
<append>true</append>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${PARTNER_LOG_DIR}/partner-core.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>20MB</maxFileSize>
<maxHistory>14</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="${LOG_LEVEL}">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="PARTNER_FILE"/>
</root>
</configuration>
@@ -0,0 +1,80 @@
package experimental;
import org.junit.jupiter.api.Test;
import org.w3c.dom.DOMException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import javax.xml.parsers.DocumentBuilderFactory;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ExternalNodeImportTest {
private static Node importExternalNode(Document targetDocument, Node externalNode) {
Node nodeToImport = externalNode;
if (externalNode != null && externalNode.getNodeType() == Node.DOCUMENT_NODE) {
nodeToImport = ((Document) externalNode).getDocumentElement();
}
if (nodeToImport == null) {
throw new IllegalArgumentException("nodeToImport must not be null");
}
if (nodeToImport.getOwnerDocument() == targetDocument) {
return nodeToImport.cloneNode(true);
}
return targetDocument.importNode(nodeToImport, true);
}
private static Document newDocument() throws Exception {
return DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.newDocument();
}
@Test
void appendForeignElementDirectlyShouldThrow() throws Exception {
Document targetDocument = newDocument();
Element inputRoot = targetDocument.createElement("input");
targetDocument.appendChild(inputRoot);
Document externalDocument = newDocument();
Element externalInputs = externalDocument.createElement("inputs");
externalDocument.appendChild(externalInputs);
assertThrows(DOMException.class, () -> inputRoot.appendChild(externalInputs));
}
@Test
void importForeignElementShouldWork() throws Exception {
Document targetDocument = newDocument();
Element inputRoot = targetDocument.createElement("input");
targetDocument.appendChild(inputRoot);
Document externalDocument = newDocument();
Element externalInputs = externalDocument.createElement("inputs");
externalDocument.appendChild(externalInputs);
Node imported = importExternalNode(targetDocument, externalInputs);
inputRoot.appendChild(imported);
assertEquals("inputs", inputRoot.getFirstChild().getNodeName());
}
@Test
void importExternalDocumentShouldUseDocumentElement() throws Exception {
Document targetDocument = newDocument();
Element inputRoot = targetDocument.createElement("input");
targetDocument.appendChild(inputRoot);
Document externalDocument = newDocument();
Element externalInputs = externalDocument.createElement("inputs");
externalDocument.appendChild(externalInputs);
Node imported = importExternalNode(targetDocument, externalDocument);
inputRoot.appendChild(imported);
assertEquals("inputs", inputRoot.getFirstChild().getNodeName());
}
}
@@ -7,7 +7,7 @@ import io.modelcontextprotocol.server.McpStatelessServerFeatures;
import io.modelcontextprotocol.server.McpStatelessSyncServer;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.jupiter.api.Test;
import work.slhaf.partner.common.mcp.InProcessMcpTransport;
import work.slhaf.partner.core.action.runner.mcp.InProcessMcpTransport;
import java.util.List;
@@ -52,29 +52,51 @@ class BuiltinActionRegistryTest {
}
@Test
void testInitRegistersMetaActionsAndMountsRunner() throws Exception {
void testInitMountsRunner() throws Exception {
ActionCapability actionCapability = mock(ActionCapability.class);
RunnerClient runnerClient = mock(RunnerClient.class);
when(actionCapability.runnerClient()).thenReturn(runnerClient);
BuiltinActionRegistry registry = spy(new BuiltinActionRegistry());
doReturn(List.of(
buildDefinition("echo", buildMetaActionInfo("echo"), params -> params.get("value").toString())
)).when(registry).buildDefaultActionDefinitions();
BuiltinActionRegistry registry = new BuiltinActionRegistry();
injectActionCapability(registry, actionCapability);
registry.init();
verify(runnerClient).setBuiltinActionRegistry(registry);
}
@Test
void testRegisterProviderRegistersDefinitionsAndMetaActions() throws Exception {
ActionCapability actionCapability = mock(ActionCapability.class);
BuiltinActionRegistry registry = new BuiltinActionRegistry();
injectActionCapability(registry, actionCapability);
BuiltinActionProvider provider = new BuiltinActionProvider() {
@Override
public List<BuiltinActionRegistry.BuiltinActionDefinition> provideBuiltinActions() {
return List.of(buildDefinition("echo", buildMetaActionInfo("echo"), params -> params.get("value").toString()));
}
@Override
public String createActionKey(String actionName) {
return BUILTIN_LOCATION + "::" + actionName;
}
};
registry.register(provider);
verify(actionCapability).registerMetaActions(argThat(metaActions ->
metaActions.containsKey("builtin::echo")
&& "echo".equals(metaActions.get("builtin::echo").getDescription())
));
verify(runnerClient).setBuiltinActionRegistry(registry);
Assertions.assertEquals("hello", registry.call("builtin::echo", Map.of("value", "hello")));
}
@Test
void testCallReturnsStringifiedResults() {
void testCallReturnsStringifiedResults() throws Exception {
ActionCapability actionCapability = mock(ActionCapability.class);
BuiltinActionRegistry registry = new BuiltinActionRegistry();
injectActionCapability(registry, actionCapability);
registry.defineBuiltinAction("echo", buildMetaActionInfo("echo"), params -> params.get("value").toString());
registry.defineBuiltinAction("json", buildMetaActionInfo("json"), params -> Map.of("ok", true).toString());
registry.defineBuiltinAction("nil", buildMetaActionInfo("nil"), params -> null);
@@ -91,8 +113,10 @@ class BuiltinActionRegistryTest {
}
@Test
void testCallPropagatesInvokerFailure() {
void testCallPropagatesInvokerFailure() throws Exception {
ActionCapability actionCapability = mock(ActionCapability.class);
BuiltinActionRegistry registry = new BuiltinActionRegistry();
injectActionCapability(registry, actionCapability);
registry.defineBuiltinAction("boom", buildMetaActionInfo("boom"), params -> {
throw new IllegalStateException("boom");
});
@@ -0,0 +1,65 @@
package work.slhaf.partner.module.action.planner;
import com.alibaba.fastjson2.JSONObject;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import work.slhaf.partner.core.action.ActionCapability;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.framework.agent.support.Result;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class ActionPlannerAssemblyHelperTest {
private static void injectField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
private static Object getField(Object target, String fieldName) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(target);
}
@Test
void shouldFixNonZeroBasedOrdersWithoutInjectingNullActionKeyList() throws Exception {
ActionCapability actionCapability = Mockito.mock(ActionCapability.class);
Mockito.when(actionCapability.loadMetaActionInfo(Mockito.anyString()))
.thenReturn(Result.success(new MetaActionInfo(
false,
null,
Map.of(),
"desc",
Set.of(),
Set.of(),
Set.of(),
false,
new JSONObject()
)));
ActionPlanner planner = new ActionPlanner();
injectField(planner, "actionCapability", actionCapability);
Object helper = getField(planner, "assemblyHelper");
Method fixDependencies = helper.getClass().getDeclaredMethod("fixDependencies", Map.class);
fixDependencies.setAccessible(true);
Map<Integer, List<String>> chain = new LinkedHashMap<>();
chain.put(1, new ArrayList<>(List.of("action_a")));
chain.put(2, new ArrayList<>(List.of("action_b")));
boolean fixed = (boolean) fixDependencies.invoke(helper, chain);
assertTrue(fixed);
assertEquals(List.of(1, 2), new ArrayList<>(chain.keySet()));
assertEquals(List.of("action_a"), chain.get(1));
assertEquals(List.of("action_b"), chain.get(2));
assertFalse(chain.containsValue(null));
}
}
@@ -0,0 +1,50 @@
package work.slhaf.partner.module.action.planner.evaluator.entity;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class EvaluatorResultTest {
@Test
void shouldReturnEmptyMapWhenPrimaryActionChainIsNull() {
EvaluatorResult result = new EvaluatorResult();
Map<Integer, List<String>> chain = result.getPrimaryActionChainAsMap();
assertNotNull(chain);
assertTrue(chain.isEmpty());
}
@Test
void shouldNormalizeNullActionKeysToEmptyList() {
EvaluatorResult result = new EvaluatorResult();
EvaluatorResult.ChainElement element = new EvaluatorResult.ChainElement();
element.setOrder(1);
element.setActionKeys(null);
result.setPrimaryActionChain(List.of(element));
Map<Integer, List<String>> chain = result.getPrimaryActionChainAsMap();
assertEquals(1, chain.size());
assertNotNull(chain.get(1));
assertTrue(chain.get(1).isEmpty());
}
@Test
void shouldCopyActionKeyListDefensively() {
EvaluatorResult result = new EvaluatorResult();
EvaluatorResult.ChainElement element = new EvaluatorResult.ChainElement();
element.setOrder(1);
List<String> keys = new ArrayList<>(List.of("a"));
element.setActionKeys(keys);
result.setPrimaryActionChain(List.of(element));
Map<Integer, List<String>> chain = result.getPrimaryActionChainAsMap();
keys.add("b");
assertEquals(new LinkedHashMap<>(Map.of(1, List.of("a"))), new LinkedHashMap<>(chain));
}
}
@@ -14,7 +14,7 @@ import java.util.List;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.*;
class CommunicationProducerTest {
@@ -32,6 +32,20 @@ class CommunicationProducerTest {
method.invoke(producer, context, response);
}
private static String invokeBuildInputXml(
CommunicationProducer producer,
PartnerRunningFlowContext context,
List<?> communicationBlocks
) throws Exception {
Method method = CommunicationProducer.class.getDeclaredMethod(
"buildInputXml",
PartnerRunningFlowContext.class,
List.class
);
method.setAccessible(true);
return (String) method.invoke(producer, context, communicationBlocks);
}
private static void setField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
@@ -72,6 +86,24 @@ class CommunicationProducerTest {
assertEquals("[[AGENT]: self]:\n\nnormal reply", chatMessages.get(1).getContent());
}
@Test
void shouldBuildInputXmlWithoutExtraWrapper() throws Exception {
StubCognitionCapability cognitionCapability = new StubCognitionCapability();
CommunicationProducer producer = new CommunicationProducer();
setField(producer, "cognitionCapability", cognitionCapability);
String xml = invokeBuildInputXml(
producer,
PartnerRunningFlowContext.fromUser("user-1", "hello"),
List.of()
);
assertTrue(xml.contains("<input>"));
assertTrue(xml.contains("<inputs>"));
assertTrue(xml.contains("<input interval-to-first=\"0\">hello</input>"));
assertFalse(xml.contains("<wrapper>"));
}
private static final class StubCognitionCapability implements CognitionCapability {
private final ContextWorkspace contextWorkspace = new ContextWorkspace();
private final List<Message> chatMessages = new ArrayList<>();
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>work.slhaf.partner</groupId>
<artifactId>partner-external-modules</artifactId>
<version>0.5.0</version>
</parent>
<artifactId>partner-onebot-adapter</artifactId>
<dependencies>
<dependency>
<groupId>work.slhaf.partner</groupId>
<artifactId>partner-core</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>work.slhaf.partner</groupId>
<artifactId>partner-framework</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<artifactSet>
<excludes>
<exclude>work.slhaf.partner:partner-framework</exclude>
<exclude>work.slhaf.partner:partner-core</exclude>
<exclude>work.slhaf.partner:partner-external-modules</exclude>
</excludes>
</artifactSet>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>process-sources</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
<configuration>
<jvmTarget>${maven.compiler.target}</jvmTarget>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,16 @@
package work.slhaf.partner.external.onebot;
import work.slhaf.partner.external.onebot.gateway.OnebotGatewayRegistration;
import work.slhaf.partner.framework.agent.Agent;
public class OnebotAdapterBootstrap extends Agent.AgentBootstrap{
protected OnebotAdapterBootstrap(Agent.AgentApp agentApp) {
super(agentApp);
}
@Override
protected void bootstrap() {
addGatewayRegistration(new OnebotGatewayRegistration());
}
}
@@ -0,0 +1,194 @@
package work.slhaf.partner.external.onebot.gateway;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.java_websocket.WebSocket;
import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.api.InputData;
import work.slhaf.partner.api.InteractionEvent;
import work.slhaf.partner.external.onebot.v11.*;
import work.slhaf.partner.framework.agent.interaction.AgentGateway;
import work.slhaf.partner.runtime.PartnerRunningFlowContext;
import java.net.InetSocketAddress;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
@Slf4j
public class OnebotGateway extends WebSocketServer implements AgentGateway<InputData, PartnerRunningFlowContext> {
private final AtomicReference<WebSocket> activeConnection = new AtomicReference<>();
private final AtomicBoolean launched = new AtomicBoolean(false);
private final String path;
private final String accessToken;
private final AtomicLong lastHeartbeatAt = new AtomicLong();
private final OneBotV11ResponseDispatcher responseDispatcher = new OneBotV11ResponseDispatcher();
public OnebotGateway(int port, @NotNull String hostname, @NotNull String path, String accessToken) {
super(new InetSocketAddress(hostname, port));
this.setReuseAddr(true);
this.path = path;
this.accessToken = accessToken;
OneBotV11ActionExecutor.bindConnectionReference(this.activeConnection);
log.info("Onebot will start with {}: {}, {}", hostname, port, path);
}
@Override
public void launch() {
if (!launched.compareAndSet(false, true)) {
return;
}
this.start();
}
@Override
public PartnerRunningFlowContext parseRunningFlowContext(InputData inputData) {
PartnerRunningFlowContext context = PartnerRunningFlowContext.fromUser(inputData.getSource(), inputData.getContent(),System.currentTimeMillis(), getChannelName());
inputData.getMeta().forEach(context::putUserInfo);
return context;
}
@Override
public void close() {
try {
responseDispatcher.close();
for (WebSocket webSocket : getConnections()) {
if (webSocket != null && webSocket.isOpen()) {
webSocket.close(1001, "Server shutting down");
}
}
if (launched.get()) {
super.stop(1000);
}
} catch (Exception e) {
log.warn("关闭 OneBotGateway 失败", e);
} finally {
activeConnection.set(null);
launched.set(false);
}
}
@Override
@NotNull
public String getChannelName() {
return "onebot_channel";
}
@Override
public void response(@NotNull InteractionEvent event) {
WebSocket conn = activeConnection.get();
if (conn == null || !conn.isOpen()) {
log.warn("No active OneBot connection for response target: {}", event.getTarget());
return;
}
responseDispatcher.accept(event);
}
@Override
public void onOpen(WebSocket conn, ClientHandshake handshake) {
String actualPath = handshake.getResourceDescriptor();
if (!actualPath.equals(path)) {
conn.close(1008, "Unsupported OneBot path: " + actualPath);
return;
}
if (!verifyAccessToken(handshake)) {
conn.close(1008, "Invalid access token");
return;
}
WebSocket old = activeConnection.getAndSet(conn);
if (old != null && old != conn && old.isOpen()) {
old.close(1001, "Replaced by new OneBot connection");
}
log.info("OneBot connected: {}", conn.getRemoteSocketAddress());
}
private boolean verifyAccessToken(ClientHandshake handshake) {
if (accessToken == null || accessToken.isBlank()) {
return true;
}
String authorization = handshake.getFieldValue("Authorization");
return ("Bearer " + accessToken).equals(authorization);
}
@Override
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
log.info("OneBot connection closed: {}, code={}, reason={}, remote={}",
conn.getRemoteSocketAddress(), code, reason, remote);
activeConnection.compareAndSet(conn, null);
}
@Override
public void onMessage(WebSocket conn, String message) {
try {
JSONObject json = JSON.parseObject(message);
if (json.containsKey("echo")) {
handleActionResponse(json);
return;
}
OneBotV11Event event = OneBotV11EventCodec.parse(json);
if (event == null) {
log.debug("Ignore unsupported OneBot payload: {}", message);
return;
}
handleEvent(event);
} catch (Exception e) {
log.warn("Invalid OneBot payload: {}", message, e);
}
}
private void handleActionResponse(JSONObject response) {
log.debug("OneBot action response: {}", response);
}
private void handleEvent(OneBotV11Event event) {
if (event instanceof OneBotV11MetaEvent metaEvent) {
if (metaEvent.isHeartbeat()) {
lastHeartbeatAt.set(System.currentTimeMillis());
log.debug("OneBot heartbeat received");
}
return;
}
if (event instanceof OneBotV11MessageEvent messageEvent) {
handleMessageEvent(messageEvent);
return;
}
log.debug("Ignore unsupported OneBot event: {}", event);
}
private void handleMessageEvent(OneBotV11MessageEvent event) {
if (!event.isPrivateMessage()) {
log.debug("Ignore non-private OneBot message, type={}", event.getMessageType().getDisplayName());
return;
}
InputData inputData = OneBotV11EventCodec.toInputData(event);
if (inputData == null) {
log.debug("Ignore empty OneBot private message");
return;
}
receive(inputData);
}
@Override
public void onError(WebSocket conn, Exception ex) {
log.error("OneBotGateway error", ex);
}
@Override
public void onStart() {
log.info("OneBotGateway 已启动...");
}
}
@@ -0,0 +1,19 @@
package work.slhaf.partner.external.onebot.gateway
import work.slhaf.partner.framework.agent.interaction.AgentGateway
import work.slhaf.partner.framework.agent.interaction.AgentGatewayRegistration
class OnebotGatewayRegistration : AgentGatewayRegistration {
override val channelName: String = "onebot_channel"
override fun create(params: Map<String, String>): AgentGateway<*, *> {
val port = params["port"]?.toIntOrNull() ?: 29700
val hostname = params["hostname"] ?: "127.0.0.1"
val path = params["path"] ?: "/onebot/v11/ws"
val token = params["token"]
require(port > 0) { "port must be greater than 0" }
return OnebotGateway(port, hostname, path, token)
}
}
@@ -0,0 +1,54 @@
package work.slhaf.partner.external.onebot.v11
import com.alibaba.fastjson2.JSONObject
import org.java_websocket.WebSocket
import java.util.*
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
@OptIn(ExperimentalAtomicApi::class)
object OneBotV11ActionExecutor {
private lateinit var activeConnection: AtomicReference<WebSocket>
@JvmStatic
fun bindConnectionReference(reference: AtomicReference<WebSocket>) {
activeConnection = reference
}
@JvmStatic
fun sendMessage(target: String, message: String): Boolean {
val action = buildSendMessageAction(target, message) ?: return false
activeConnection.load().send(action.toJSONString())
return true
}
private fun buildSendMessageAction(target: String, message: String): JSONObject? {
val segments = target.split(":")
if (segments.size < 3 || segments[0] != "onebot") {
return null
}
val params = JSONObject()
val actionName = when (segments[1]) {
"private" -> {
params["user_id"] = segments[2].toLongOrNull() ?: return null
"send_private_msg"
}
"group" -> {
params["group_id"] = segments[2].toLongOrNull() ?: return null
"send_group_msg"
}
else -> return null
}
params["message"] = message
val action = JSONObject()
action["action"] = actionName
action["params"] = params
action["echo"] = UUID.randomUUID().toString()
return action
}
}
@@ -0,0 +1,44 @@
package work.slhaf.partner.external.onebot.v11
sealed class OneBotV11Event {
abstract val postType: OneBotV11PostType
}
enum class OneBotV11PostType {
MESSAGE,
META_EVENT
}
enum class OneBotV11MessageType(val displayName: String) {
PRIVATE("私聊"),
GROUP("群聊"),
UNKNOWN("未知")
}
data class OneBotV11MessageEvent(
val messageType: OneBotV11MessageType,
val userId: Long,
val groupId: Long?,
val message: Any?,
val rawMessage: String?
) : OneBotV11Event() {
override val postType: OneBotV11PostType = OneBotV11PostType.MESSAGE
fun isPrivateMessage(): Boolean = messageType == OneBotV11MessageType.PRIVATE
fun isGroupMessage(): Boolean = messageType == OneBotV11MessageType.GROUP
fun routeTarget(): String? = when {
isPrivateMessage() -> "onebot:private:$userId"
isGroupMessage() && groupId != null -> "onebot:group:$groupId:$userId"
else -> null
}
}
data class OneBotV11MetaEvent(
val metaEventType: String
) : OneBotV11Event() {
override val postType: OneBotV11PostType = OneBotV11PostType.META_EVENT
fun isHeartbeat(): Boolean = metaEventType == "heartbeat"
}
@@ -0,0 +1,66 @@
package work.slhaf.partner.external.onebot.v11
import com.alibaba.fastjson2.JSONArray
import com.alibaba.fastjson2.JSONObject
import work.slhaf.partner.api.InputData
object OneBotV11EventCodec {
@JvmStatic
fun parse(json: JSONObject): OneBotV11Event? {
return when (json.getString("post_type")) {
"message" -> parseMessage(json)
"meta_event" -> parseMetaEvent(json)
else -> null
}
}
@JvmStatic
fun toInputData(event: OneBotV11MessageEvent): InputData? {
val target = event.routeTarget() ?: return null
val content = extractText(event).takeIf { it.isNotBlank() } ?: return null
val inputData = InputData(target, content)
inputData.addMeta("message_type", event.messageType.displayName)
event.groupId?.let { inputData.addMeta("group_id", it.toString()) }
return inputData
}
private fun extractText(event: OneBotV11MessageEvent): String {
event.rawMessage?.takeIf { it.isNotBlank() }?.let { return it }
val message = event.message ?: return ""
return when (message) {
is String -> message
is JSONArray -> message.asSequence()
.filterIsInstance<JSONObject>()
.filter { it.getString("type") == "text" }
.mapNotNull { it.getJSONObject("data")?.getString("text") }
.joinToString("")
else -> message.toString()
}
}
private fun parseMessage(json: JSONObject): OneBotV11MessageEvent {
return OneBotV11MessageEvent(
messageType = parseMessageType(json.getString("message_type")),
userId = json.getLongValue("user_id"),
groupId = json.getLong("group_id"),
message = json["message"],
rawMessage = json.getString("raw_message")
)
}
private fun parseMetaEvent(json: JSONObject): OneBotV11MetaEvent {
return OneBotV11MetaEvent(
metaEventType = json.getString("meta_event_type") ?: ""
)
}
private fun parseMessageType(value: String?): OneBotV11MessageType {
return when (value) {
"private" -> OneBotV11MessageType.PRIVATE
"group" -> OneBotV11MessageType.GROUP
else -> OneBotV11MessageType.UNKNOWN
}
}
}
@@ -0,0 +1,262 @@
package work.slhaf.partner.external.onebot.v11
import work.slhaf.partner.api.InteractionEvent
import work.slhaf.partner.api.ModuleEvent
import work.slhaf.partner.api.ReplyEvent
import work.slhaf.partner.api.SystemEvent
import java.util.concurrent.ConcurrentHashMap
/**
* Converts Partner streaming interaction events into readable OneBot chat bubbles.
*
* OneBot/QQ messages are discrete chat messages, not frontend delta patches. This dispatcher buffers
* ReplyEvent.APPEND chunks and sends complete natural blocks whenever possible.
*/
class OneBotV11ResponseDispatcher(
private val actionExecutor: OneBotV11ActionExecutor = OneBotV11ActionExecutor,
private val maxMessageChars: Int = 1200,
private val softFlushChars: Int = 1000,
) : AutoCloseable {
private val buffers = ConcurrentHashMap<String, StringBuilder>()
fun accept(event: InteractionEvent) {
when (event) {
is ReplyEvent -> acceptReply(event)
is ModuleEvent -> {
flush(event.target)
sendStandalone(event.target, event.data.content)
}
is SystemEvent -> {
flush(event.target)
sendStandalone(event.target, formatSystemEvent(event))
}
}
}
fun flush(target: String) {
val buffer = buffers[target] ?: return
val content = buffer.toString().trim()
buffer.clear()
if (content.isNotBlank()) {
sendSegments(target, content)
}
}
override fun close() {
buffers.keys.toList().forEach(::flush)
}
private fun acceptReply(event: ReplyEvent) {
val content = event.content
if (content.isNotBlank()) {
val buffer = buffers.computeIfAbsent(event.target) { StringBuilder() }
when (event.mode) {
ReplyEvent.ContentMode.APPEND -> buffer.append(content)
ReplyEvent.ContentMode.REPLACE -> {
buffer.clear()
buffer.append(content)
}
}
flushCompletedBlocks(event.target, buffer)
}
if (event.status == InteractionEvent.EventStatus.DONE || event.status == InteractionEvent.EventStatus.ERROR) {
flush(event.target)
}
}
private fun flushCompletedBlocks(target: String, buffer: StringBuilder) {
while (buffer.isNotEmpty()) {
if (hasUnclosedCodeFence(buffer)) {
return
}
val completedBlockEnd = findCompletedBlockEnd(buffer)
if (completedBlockEnd > 0) {
val block = buffer.substring(0, completedBlockEnd).trim()
buffer.delete(0, completedBlockEnd)
trimLeadingBlankLines(buffer)
if (block.isNotBlank()) {
sendSegments(target, block)
}
continue
}
if (buffer.length >= softFlushChars) {
val splitAt = findNaturalSplit(buffer, maxMessageChars.coerceAtMost(buffer.length))
if (splitAt != null && splitAt > 0) {
val block = buffer.substring(0, splitAt).trim()
buffer.delete(0, splitAt)
trimLeadingBlankLines(buffer)
if (block.isNotBlank()) {
sendSegments(target, block)
}
continue
}
}
return
}
}
private fun findCompletedBlockEnd(buffer: CharSequence): Int {
val text = buffer.toString()
val blankLine = findFirstBlankLine(text)
if (blankLine <= 0) {
return -1
}
val candidate = text.substring(0, blankLine).trim()
if (candidate.isBlank()) {
return blankLine
}
val nextBlockStart = skipBlankLines(text, blankLine)
if (isHeadingOnly(candidate) && nextBlockStart < text.length) {
val nextBlankLine = findFirstBlankLine(text, nextBlockStart)
if (nextBlankLine > 0) {
return nextBlankLine
}
return -1
}
return blankLine
}
private fun sendSegments(target: String, content: String) {
splitForOneBot(content).forEach { segment ->
actionExecutor.sendMessage(target, segment)
}
}
private fun sendStandalone(target: String, content: String) {
if (content.isBlank()) {
return
}
sendSegments(target, content.trim())
}
private fun splitForOneBot(content: String): List<String> {
val result = mutableListOf<String>()
val buffer = StringBuilder(content.trim())
while (buffer.length > maxMessageChars) {
val splitAt = findNaturalSplit(buffer, maxMessageChars)
?.takeIf { it > 0 }
?: maxMessageChars
val segment = buffer.substring(0, splitAt).trim()
if (segment.isNotBlank()) {
result.add(segment)
}
buffer.delete(0, splitAt)
trimLeadingBlankLines(buffer)
}
val rest = buffer.toString().trim()
if (rest.isNotBlank()) {
result.add(rest)
}
return result
}
private fun findNaturalSplit(text: CharSequence, preferredMax: Int): Int? {
val max = preferredMax.coerceAtMost(text.length)
if (max <= 0) {
return null
}
val searchStart = (max * 0.45).toInt().coerceAtLeast(1)
val priorities = listOf(
"\n\n", "\r\n\r\n",
"。", "!", "?", "!", "?",
";", ";",
":", ":",
",", ",",
"\n",
" "
)
for (delimiter in priorities) {
val idx = lastIndexOf(text, delimiter, max, searchStart)
if (idx >= 0) {
return idx + delimiter.length
}
}
return null
}
private fun lastIndexOf(text: CharSequence, delimiter: String, endExclusive: Int, startInclusive: Int): Int {
var i = endExclusive - delimiter.length
while (i >= startInclusive) {
var matched = true
for (j in delimiter.indices) {
if (text[i + j] != delimiter[j]) {
matched = false
break
}
}
if (matched) {
return i
}
i--
}
return -1
}
private fun hasUnclosedCodeFence(text: CharSequence): Boolean {
var count = 0
var index = 0
val value = text.toString()
while (true) {
val found = value.indexOf("```", index)
if (found < 0) {
break
}
count++
index = found + 3
}
return count % 2 != 0
}
private fun findFirstBlankLine(text: String, startIndex: Int = 0): Int {
val lf = text.indexOf("\n\n", startIndex)
val crlf = text.indexOf("\r\n\r\n", startIndex)
return when {
lf < 0 -> crlf.takeIf { it >= 0 }?.plus(4) ?: -1
crlf < 0 -> lf + 2
lf < crlf -> lf + 2
else -> crlf + 4
}
}
private fun skipBlankLines(text: String, index: Int): Int {
var i = index
while (i < text.length && (text[i] == '\n' || text[i] == '\r')) {
i++
}
return i
}
private fun trimLeadingBlankLines(buffer: StringBuilder) {
while (buffer.isNotEmpty() && (buffer.first() == '\n' || buffer.first() == '\r')) {
buffer.deleteCharAt(0)
}
}
private fun isHeadingOnly(text: String): Boolean {
val lines = text.lines().filter { it.isNotBlank() }
return lines.size == 1 && lines.first().trimStart().startsWith("#")
}
private fun formatSystemEvent(event: SystemEvent): String {
return buildString {
append(event.title)
if (event.content.isNotBlank()) {
append('\n')
append(event.content)
}
}
}
}
@@ -0,0 +1,8 @@
import work.slhaf.partner.Main;
import work.slhaf.partner.framework.agent.Agent;
public class TestMain {
public static void main(String[] args) {
Agent.newAgent(Main.class).launch();
}
}
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>work.slhaf.partner</groupId>
<artifactId>partner</artifactId>
<version>0.5.0</version>
</parent>
<artifactId>partner-external-modules</artifactId>
<packaging>pom</packaging>
<modules>
<module>Partner-Onebot-Adapter</module>
</modules>
</project>
+8 -3
View File
@@ -4,12 +4,12 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>work.slhaf</groupId>
<artifactId>Partner</artifactId>
<groupId>work.slhaf.partner</groupId>
<artifactId>partner</artifactId>
<version>0.5.0</version>
</parent>
<artifactId>Partner-Framework</artifactId>
<artifactId>partner-framework</artifactId>
<dependencies>
<dependency>
@@ -83,6 +83,11 @@
<artifactId>openai-java</artifactId>
<version>4.26.0</version>
</dependency>
<dependency>
<groupId>work.slhaf.partner</groupId>
<artifactId>partner-interaction-api</artifactId>
<version>0.5.0</version>
</dependency>
</dependencies>
<properties>
@@ -2,6 +2,7 @@ package work.slhaf.partner.framework.agent;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.reflections.util.ClasspathHelper;
import work.slhaf.partner.framework.agent.config.ConfigCenter;
import work.slhaf.partner.framework.agent.config.Configurable;
import work.slhaf.partner.framework.agent.exception.AgentStartupException;
@@ -9,16 +10,20 @@ import work.slhaf.partner.framework.agent.exception.ExceptionReporter;
import work.slhaf.partner.framework.agent.exception.ExceptionReporterHandler;
import work.slhaf.partner.framework.agent.factory.AgentRegisterFactory;
import work.slhaf.partner.framework.agent.factory.context.AgentContext;
import work.slhaf.partner.framework.agent.factory.context.AgentRegisterContext;
import work.slhaf.partner.framework.agent.interaction.AgentGatewayRegistration;
import work.slhaf.partner.framework.agent.interaction.AgentGatewayRegistry;
import work.slhaf.partner.framework.agent.log.LogAdviceProvider;
import work.slhaf.partner.framework.agent.log.TraceRecorder;
import work.slhaf.partner.framework.agent.log.TraceSinkRegistry;
import work.slhaf.partner.framework.agent.model.ModelRuntimeRegistry;
import work.slhaf.partner.framework.agent.state.StateCenter;
import java.io.File;
import java.lang.reflect.Constructor;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.*;
/**
* <h2>Agent 启动入口</h2>
@@ -33,7 +38,8 @@ public final class Agent {
public static class AgentApp {
private final Class<?> applicationClass;
private final Set<String> scanPackages = new LinkedHashSet<>();
private final Set<String> scanDirs = new LinkedHashSet<>();
private final Set<AgentGatewayRegistration> gatewayRegistrations = new LinkedHashSet<>();
private final Set<ExceptionReporter> exceptionReporters = new LinkedHashSet<>();
private final Set<Configurable> configurables = new LinkedHashSet<>();
@@ -41,40 +47,47 @@ public final class Agent {
private final Set<LifecycleHook> postShutdownHooks = new LinkedHashSet<>();
private AgentApp(Class<?> clazz) {
this.applicationClass = clazz;
this.scanPackages.add(clazz.getPackageName());
}
public AgentApp addGatewayRegistration(AgentGatewayRegistration... registrations) {
private void addScanPackage(String packageName) {
if (packageName != null && !packageName.isBlank()) {
this.scanPackages.add(packageName);
}
}
private void addScanDir(String scanDir) {
if (scanDir != null && !scanDir.isBlank()) {
this.scanDirs.add(scanDir);
}
}
private void addGatewayRegistration(AgentGatewayRegistration... registrations) {
this.gatewayRegistrations.addAll(Set.of(registrations));
return this;
}
public AgentApp addConfigurable(Configurable configurable) {
private void addConfigurable(Configurable configurable) {
this.configurables.add(configurable);
return this;
}
public AgentApp addExceptionReporter(ExceptionReporter... exceptionReporters) {
private void addExceptionReporter(ExceptionReporter... exceptionReporters) {
this.exceptionReporters.addAll(Set.of(exceptionReporters));
return this;
}
public AgentApp addPreShutdownHook(String name, Runnable action) {
return addPreShutdownHook(name, 0, action);
private void addPreShutdownHook(String name, Runnable action) {
addPreShutdownHook(name, 0, action);
}
public AgentApp addPreShutdownHook(String name, int order, Runnable action) {
private void addPreShutdownHook(String name, int order, Runnable action) {
this.preShutdownHooks.add(new LifecycleHook(name, order, action));
return this;
}
public AgentApp addPostShutdownHook(String name, Runnable action) {
return addPostShutdownHook(name, 0, action);
private void addPostShutdownHook(String name, Runnable action) {
addPostShutdownHook(name, 0, action);
}
public AgentApp addPostShutdownHook(String name, int order, Runnable action) {
private void addPostShutdownHook(String name, int order, Runnable action) {
this.postShutdownHooks.add(new LifecycleHook(name, order, action));
return this;
}
public boolean launch() {
@@ -83,6 +96,13 @@ public final class Agent {
ConfigCenter.INSTANCE.toString();
StateCenter.INSTANCE.toString();
Path externalModuleDir = ConfigCenter.INSTANCE.getPaths().getResourcesDir().resolve("module");
addScanDir(externalModuleDir.toString());
AgentRegisterContext bootstrapContext = buildRegisterContext();
runBootstraps(bootstrapContext);
AgentRegisterContext registerContext = buildRegisterContext();
// Keep startup order explicit so registries are ready before component scanning.
for (ExceptionReporter exceptionReporter : exceptionReporters) {
exceptionReporter.register();
@@ -102,9 +122,7 @@ public final class Agent {
registerShutdownHooks();
Path externalModuleDir = ConfigCenter.INSTANCE.getPaths().getResourcesDir().resolve("module");
AgentRegisterFactory.addScanDir(externalModuleDir.toString());
AgentRegisterFactory.launch(applicationClass.getPackageName());
AgentRegisterFactory.launch(registerContext);
// Try to init configurable, and start config listening
ConfigCenter.INSTANCE.initAll();
@@ -120,6 +138,78 @@ public final class Agent {
}
}
private AgentRegisterContext buildRegisterContext() {
return new AgentRegisterContext(new ArrayList<>(buildScanUrls()));
}
private Set<URL> buildScanUrls() {
Set<URL> urls = new LinkedHashSet<>();
for (String packageName : scanPackages) {
urls.addAll(ClasspathHelper.forPackage(packageName));
}
for (String scanDir : scanDirs) {
urls.addAll(scanDirToUrls(scanDir));
}
return urls;
}
private Set<URL> scanDirToUrls(String scanDir) {
Set<URL> urls = new LinkedHashSet<>();
File file = new File(scanDir);
if (!file.exists() || !file.isDirectory()) {
return urls;
}
try {
File[] files = file.listFiles();
if (files == null) {
return urls;
}
for (File item : files) {
if (item.getName().endsWith(".jar")) {
urls.add(item.toURI().toURL());
}
}
return urls;
} catch (Exception e) {
throw new AgentStartupException("Failed to load scan dir URLs from: " + scanDir, "agent-bootstrap", e);
}
}
private void runBootstraps(AgentRegisterContext context) {
context.getReflections().getSubTypesOf(AgentBootstrap.class).stream()
.filter(this::isConcreteBootstrap)
.map(this::instantiateBootstrap)
.sorted(Comparator.comparingInt(AgentBootstrap::order))
.forEach(AgentBootstrap::bootstrap);
}
private boolean isConcreteBootstrap(Class<? extends AgentBootstrap> bootstrapClass) {
int modifiers = bootstrapClass.getModifiers();
return !bootstrapClass.isInterface()
&& !bootstrapClass.isAnnotation()
&& !bootstrapClass.isEnum()
&& !bootstrapClass.isArray()
&& !bootstrapClass.isPrimitive()
&& !Modifier.isAbstract(modifiers)
&& !bootstrapClass.isSynthetic()
&& !bootstrapClass.isAnonymousClass()
&& !bootstrapClass.isLocalClass();
}
private AgentBootstrap instantiateBootstrap(Class<? extends AgentBootstrap> bootstrapClass) {
try {
Constructor<? extends AgentBootstrap> constructor = bootstrapClass.getDeclaredConstructor(AgentApp.class);
constructor.setAccessible(true);
return constructor.newInstance(this);
} catch (Exception e) {
throw new AgentStartupException(
"Failed to instantiate AgentBootstrap: " + bootstrapClass.getName(),
"agent-bootstrap",
e
);
}
}
private void registerShutdownHooks() {
AgentContext.INSTANCE.addPreShutdownHook(
"agent-gateway-registry-close",
@@ -135,9 +225,9 @@ public final class Agent {
StateCenter.INSTANCE::save
);
AgentContext.INSTANCE.addPostShutdownHook(
"trace-recorder-close",
"trace-sink-registry-close",
90,
TraceRecorder.INSTANCE::close
TraceSinkRegistry.INSTANCE::close
);
AgentContext.INSTANCE.addPostShutdownHook(
"config-center-close",
@@ -153,4 +243,55 @@ public final class Agent {
private record LifecycleHook(String name, int order, Runnable action) {
}
public static abstract class AgentBootstrap {
private final AgentApp agentApp;
protected AgentBootstrap(AgentApp agentApp) {
this.agentApp = Objects.requireNonNull(agentApp, "agentApp");
}
public int order() {
return 0;
}
protected abstract void bootstrap();
protected final void addScanPackage(String packageName) {
agentApp.addScanPackage(packageName);
}
protected final void addScanDir(String scanDir) {
agentApp.addScanDir(scanDir);
}
protected final void addPreShutdownHook(String name, Runnable action) {
agentApp.addPreShutdownHook(name, action);
}
protected final void addPreShutdownHook(String name, int order, Runnable action) {
agentApp.addPreShutdownHook(name, order, action);
}
protected final void addPostShutdownHook(String name, Runnable action) {
agentApp.addPostShutdownHook(name, action);
}
protected final void addPostShutdownHook(String name, int order, Runnable action) {
agentApp.addPostShutdownHook(name, order, action);
}
protected final void addExceptionReporter(ExceptionReporter... exceptionReporters) {
agentApp.addExceptionReporter(exceptionReporters);
}
protected final void addConfigurable(Configurable configurable) {
agentApp.addConfigurable(configurable);
}
protected final void addGatewayRegistration(AgentGatewayRegistration... gatewayRegistrations) {
agentApp.addGatewayRegistration(gatewayRegistrations);
}
}
}
@@ -1,6 +1,5 @@
package work.slhaf.partner.framework.agent.config
import com.alibaba.fastjson2.JSON
import com.alibaba.fastjson2.JSONObject
import org.slf4j.LoggerFactory
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException
@@ -88,23 +87,41 @@ object ConfigCenter : AutoCloseable {
@Suppress("UNCHECKED_CAST")
fun initAll() {
val errorConfig = mutableMapOf<Path, String>()
registrations.forEach { (path, registration) ->
val pair = loadConfig(path, registration)
val configFile = paths.configDir.resolve(path).normalize()
val pair = loadConfig(configFile, registration)
if (pair != null) {
(registration as ConfigRegistration<Config>).init(pair.first, pair.second)
return
return@forEach
}
val defaultConfig = registration.defaultConfig()
if (defaultConfig != null) {
(registration as ConfigRegistration<Config>).init(defaultConfig, null)
return@forEach
}
val configDoc = resolveConfigDoc(registration.type())
errorConfig[path] = configDoc
}
if (errorConfig.isNotEmpty()) {
throw AgentStartupException(
"Failed to init config, related path: $path, config definition: $configDoc",
"Failed to init configurations: ${buildErrorConfigReport(errorConfig)}",
COMPONENT_NAME
)
}
}
private fun buildErrorConfigReport(errorConfig: MutableMap<Path, String>): String {
return buildString {
appendLine()
append("\n=====Config Missing=====\n")
errorConfig.forEach { (path, config) ->
appendLine("\n>>>\t$path\t>>>\n")
append(config)
appendLine()
appendLine("\n<<<\t$path\t<<<")
}
}
}
private fun resolveConfigDoc(type: Class<out Config>): String {
@@ -159,6 +176,7 @@ object ConfigCenter : AutoCloseable {
return field.isSynthetic || Modifier.isStatic(field.modifiers)
}
@Suppress("unused")
private fun handleUpsert(thisDir: Path, context: Path?) {
if (context == null || !Files.isRegularFile(context) || !isJsonFile(context)) {
return
@@ -166,6 +184,7 @@ object ConfigCenter : AutoCloseable {
reloadIfRegistered(context)
}
@Suppress("unused")
private fun handleDelete(thisDir: Path, context: Path?) {
if (context == null || !isJsonFile(context)) {
return
@@ -174,7 +193,7 @@ object ConfigCenter : AutoCloseable {
if (!registrations.containsKey(relativePath)) {
return
}
log.info("Config deleted, skipped reload: {}", relativePath)
log.debug("Config deleted, skipped reload: {}", relativePath)
}
private fun reconcileAll() {
@@ -205,10 +224,11 @@ object ConfigCenter : AutoCloseable {
private fun loadConfig(file: Path, registration: ConfigRegistration<out Config>): Pair<Config, JSONObject>? {
return try {
val json = JSON.parseObject(Files.readString(file, StandardCharsets.UTF_8))
val json = JSONObject.parseObject(Files.readString(file, StandardCharsets.UTF_8))
val config = json.toJavaObject(registration.type())
config to json
} catch (_: Exception) {
log.warn("Unable to load config from ${file.toAbsolutePath()}")
null
}
}
@@ -2,6 +2,8 @@ package work.slhaf.partner.framework.agent.exception
import com.alibaba.fastjson2.JSONObject
import org.slf4j.LoggerFactory
import work.slhaf.partner.framework.agent.log.TraceEvent
import work.slhaf.partner.framework.agent.log.TraceRecorder
abstract class AgentException @JvmOverloads constructor(
message: String,
@@ -11,7 +13,7 @@ abstract class AgentException @JvmOverloads constructor(
return ExceptionReport(
this::class.java.simpleName,
message ?: "",
this
cause
)
}
}
@@ -98,6 +100,7 @@ object LoggerExceptionReporter : ExceptionReporter {
override fun report(exception: AgentException) {
val exceptionReport = exception.toReport().toDetailedString()
TraceRecorder.record(TraceEvent("exception", JSONObject.of("exception", exceptionReport)))
log.error("exception occurred: $exceptionReport")
}
@@ -33,7 +33,11 @@ object AgentRegisterFactory {
@JvmStatic
fun launch(packageName: String) {
urls.addAll(packageNameToURL(packageName))
val registerContext = AgentRegisterContext(urls)
launch(AgentRegisterContext(urls))
}
@JvmStatic
fun launch(registerContext: AgentRegisterContext) {
// 1. 校验 Component 级别注解是否合规,避免注入到异常位置
ComponentAnnotationValidatorFactory().execute(registerContext)
// 2. 收集所有的 AgentComponent 实例
@@ -13,6 +13,7 @@ import java.lang.reflect.Modifier
*
* 注入关系:
* - `sub + standalone -> running`
* - `standalone -> sub`
* - `sub + standalone -> standalone`
* - `sub + standalone -> additionalComponent`
*
@@ -44,6 +45,11 @@ class ComponentInjectorFactory : AgentBaseFactory() {
standaloneModules.forEach { it.injectTarget.add(running.instance) }
}
subModules.forEach { sub ->
injectIntoTarget(sub.instance, standaloneInstances)
standaloneModules.forEach { it.injectTarget.add(sub.instance) }
}
standaloneModules.forEach { standalone ->
val providersForStandalone = subInstances + standaloneInstances.filter { it !== standalone.instance }
injectIntoTarget(standalone.instance, providersForStandalone)
@@ -13,6 +13,8 @@ import java.time.ZonedDateTime
object AgentContext {
private val log = LoggerFactory.getLogger(this::class.java)
private val _modules =
mutableMapOf<String, ModuleContextData<AbstractAgentModule>>()
@@ -156,7 +158,6 @@ object AgentContext {
}
fun trigger(hooks: List<ShutdownHookDesc>, instances: Instances) {
val log = LoggerFactory.getLogger(AgentContext::class.java)
hooks.sortedBy { it.order }
.forEach {
try {
@@ -168,7 +169,6 @@ object AgentContext {
}
fun triggerLifecycleHooks(hooks: List<LifecycleShutdownHookDesc>) {
val log = LoggerFactory.getLogger(AgentContext::class.java)
hooks.sortedBy { it.order }
.forEach {
try {
@@ -180,6 +180,7 @@ object AgentContext {
}
Runtime.getRuntime().addShutdownHook(Thread {
log.info("Shutdown hooks triggering...")
val instances = computeInstances()
triggerLifecycleHooks(preShutdownHooks)
shutdownHooks[ShutdownHookDesc.Type.RUNNING]?.let { trigger(it, instances) }
@@ -188,6 +189,7 @@ object AgentContext {
shutdownHooks[ShutdownHookDesc.Type.SUB]?.let { trigger(it, instances) }
shutdownHooks[ShutdownHookDesc.Type.CAPABILITY]?.let { trigger(it, instances) }
triggerLifecycleHooks(postShutdownHooks)
log.info("Shutdown hooks triggered...")
})
}
@@ -5,15 +5,22 @@ import org.reflections.scanners.Scanners
import org.reflections.util.ConfigurationBuilder
import java.lang.reflect.Method
import java.net.URL
import java.net.URLClassLoader
class AgentRegisterContext(urls: List<URL>) {
private val classLoader = URLClassLoader(
urls.toTypedArray(),
Thread.currentThread().contextClassLoader
)
val reflections: Reflections = Reflections(
ConfigurationBuilder().setScanners(
Scanners.FieldsAnnotated,
Scanners.SubTypes,
Scanners.MethodsAnnotated,
Scanners.TypesAnnotated
).setUrls(urls)
).setUrls(urls).addClassLoaders(classLoader)
)
val capabilityFactoryContext: CapabilityFactoryContext = CapabilityFactoryContext()
@@ -1,19 +1,12 @@
package work.slhaf.partner.framework.agent.interaction;
import work.slhaf.partner.framework.agent.interaction.data.InputData;
import work.slhaf.partner.api.InputData;
import work.slhaf.partner.framework.agent.interaction.flow.RunningFlowContext;
public interface AgentGateway<I extends InputData, C extends RunningFlowContext> extends ResponseChannel, AutoCloseable {
void launch();
AgentGatewayRegistration registration();
@Override
default void register() {
registration().register();
}
default void receive(I inputData) {
C parsedContext = parseRunningFlowContext(inputData);
AgentRuntime.INSTANCE.submit(parsedContext);
@@ -68,7 +68,10 @@ object AgentGatewayRegistry : Configurable, ConfigRegistration<AgentGatewayRegis
}
}
override fun defaultConfig(): AgentGatewayRegistryConfig? = null
override fun defaultConfig(): AgentGatewayRegistryConfig = AgentGatewayRegistryConfig(
"websocket_channel",
listOf(AgentGatewayChannelConfig("websocket_channel", emptyMap()))
)
override fun close() = registryLock.withLock {
val currentChannels = runningChannels.keys.toList()
@@ -4,6 +4,7 @@ import com.alibaba.fastjson2.JSONObject
import com.alibaba.fastjson2.annotation.JSONField
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import work.slhaf.partner.api.InteractionEvent
import work.slhaf.partner.framework.agent.config.Config
import work.slhaf.partner.framework.agent.config.ConfigDoc
import work.slhaf.partner.framework.agent.config.ConfigRegistration
@@ -12,7 +13,6 @@ import work.slhaf.partner.framework.agent.exception.ExceptionReporterHandler
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule
import work.slhaf.partner.framework.agent.factory.context.AgentContext
import work.slhaf.partner.framework.agent.factory.context.ModuleContextData
import work.slhaf.partner.framework.agent.interaction.data.InteractionEvent
import work.slhaf.partner.framework.agent.interaction.flow.RunningFlowContext
import work.slhaf.partner.framework.agent.support.Result
import java.nio.file.Path
@@ -2,7 +2,7 @@ package work.slhaf.partner.framework.agent.interaction
import com.alibaba.fastjson2.JSONObject
import org.slf4j.LoggerFactory
import work.slhaf.partner.framework.agent.interaction.data.InteractionEvent
import work.slhaf.partner.api.InteractionEvent
interface ResponseChannel {
@@ -10,9 +10,6 @@ interface ResponseChannel {
fun response(event: InteractionEvent)
fun register() {
AgentRuntime.registerResponseChannel(channelName, this)
}
}
object LogChannel : ResponseChannel {
@@ -22,10 +19,6 @@ object LogChannel : ResponseChannel {
override val channelName: String
get() = "log_channel"
init {
register()
}
override fun response(event: InteractionEvent) {
log.info(JSONObject.toJSONString(event))
}
@@ -12,7 +12,8 @@ import kotlin.math.min
*/
abstract class RunningFlowContext protected constructor(
inputs: List<InputEntry>,
private var firstInputEpochMillis: Long
private var firstInputEpochMillis: Long,
val resopnseChannel: String? = null
) {
/**
* 消息来源: 由谁发出
@@ -4,25 +4,19 @@ import com.alibaba.fastjson2.JSONException
import com.alibaba.fastjson2.JSONObject
import org.slf4j.LoggerFactory
import work.slhaf.partner.framework.agent.config.Config
import work.slhaf.partner.framework.agent.config.ConfigCenter
import work.slhaf.partner.framework.agent.config.ConfigRegistration
import work.slhaf.partner.framework.agent.config.Configurable
import java.nio.file.Files
import java.nio.file.Path
import java.time.ZonedDateTime
object LogAdviceProvider : Configurable, ConfigRegistration<AdviceLoggingConfig> {
private val logPath = ConfigCenter.paths.stateDir.resolve("trace").normalize().toAbsolutePath()
private val _adviceRegistry = mutableSetOf<LogAdvice<*, *>>()
val adviceRegistry: Set<LogAdvice<*, *>>
get() = _adviceRegistry
var logLevel = AdviceLoggingConfig.LogLevel.NONE
init {
Files.createDirectories(logPath)
}
@JvmOverloads
fun <I, O> createAdvice(
@@ -54,8 +48,7 @@ object LogAdviceProvider : Configurable, ConfigRegistration<AdviceLoggingConfig>
}
internal fun record(result: AdviceResult) {
val path = logPath.resolve(result.adviceTarget).normalize().toAbsolutePath()
val traceEvent = TraceEvent(path, result.toJSON(), result.finishTime.toInstant().toEpochMilli())
val traceEvent = TraceEvent(result.adviceTarget, result.toJSON(), result.finishTime.toInstant().toEpochMilli())
TraceRecorder.record(traceEvent)
}
@@ -70,6 +63,13 @@ object LogAdviceProvider : Configurable, ConfigRegistration<AdviceLoggingConfig>
logLevel = config.logLevel
}
override fun onReload(
config: AdviceLoggingConfig,
json: JSONObject?
) {
this.logLevel = config.logLevel
}
override fun defaultConfig(): AdviceLoggingConfig = AdviceLoggingConfig(AdviceLoggingConfig.LogLevel.NONE)
}
@@ -4,7 +4,7 @@ import com.alibaba.fastjson2.JSONObject
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import org.slf4j.LoggerFactory
import work.slhaf.partner.framework.agent.factory.context.AgentContext
import work.slhaf.partner.framework.agent.config.ConfigCenter
import java.io.BufferedWriter
import java.io.OutputStream
import java.nio.charset.StandardCharsets
@@ -16,11 +16,86 @@ import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.GZIPOutputStream
object TraceSinkRegistry {
private val log = LoggerFactory.getLogger(TraceSinkRegistry::class.java)
private val sinks = CopyOnWriteArrayList<TraceSink>()
private val closed = AtomicBoolean(false)
init {
FileTraceSink.register()
}
internal fun register(sink: TraceSink) {
if (closed.get()) {
log.warn("TraceSinkRegistry is closed, skip trace sink: {}", sink.javaClass.name)
return
}
if (!sinks.contains(sink)) {
sinks.add(sink)
}
}
internal fun unregister(sink: TraceSink) {
sinks.remove(sink)
}
fun publish(event: TraceEvent) {
for (sink in sinks) {
runCatching {
sink.consume(event)
}.onFailure {
log.error("Trace sink failed: {}", sink.javaClass.name, it)
}
}
}
fun close() {
if (!closed.compareAndSet(false, true)) {
return
}
sinks.forEach { sink ->
runCatching {
sink.close()
}.onFailure {
log.error("Failed to close trace sink: {}", sink.javaClass.name, it)
}
}
sinks.clear()
}
}
interface TraceSink : AutoCloseable {
fun register() {
TraceSinkRegistry.register(this)
}
fun unregister() {
TraceSinkRegistry.unregister(this)
}
fun consume(event: TraceEvent)
override fun close() {
}
}
object TraceRecorder {
@JvmStatic
fun record(event: TraceEvent) {
TraceSinkRegistry.publish(event)
}
}
object FileTraceSink : TraceSink {
private const val ACTIVE_FILE_NAME = "active.jsonl"
private const val HISTORICAL_DIR_NAME = "historical"
private const val ARCHIVED_DIR_NAME = "archived"
@@ -40,7 +115,6 @@ object TraceRecorder {
private val writerJob: Job
init {
AgentContext.addPostShutdownHook("trace-recorder-close") { close() }
writerJob = scope.launch {
try {
for (event in channel) {
@@ -54,18 +128,18 @@ object TraceRecorder {
}
}
fun record(event: TraceEvent) {
override fun consume(event: TraceEvent) {
if (closed.get()) {
log.warn("TraceRecorder is closed, skip event for path: {}", event.path)
log.warn("FileTraceSink is closed, skip event for key: {}", event.key)
return
}
val result = channel.trySend(event)
if (result.isFailure) {
log.error("Failed to enqueue trace event for path: {}", event.path, result.exceptionOrNull())
log.error("Failed to enqueue trace event for key: {}", event.key, result.exceptionOrNull())
}
}
fun close() {
override fun close() {
if (!closed.compareAndSet(false, true)) {
return
}
@@ -77,7 +151,7 @@ object TraceRecorder {
}
private fun handleEvent(event: TraceEvent) {
val basePath = event.path.normalize().toAbsolutePath()
val basePath = resolveBasePath(event.key)
runCatching {
val state = writerStates.getOrPut(basePath) { openWriterState(basePath) }
writeEvent(state, event)
@@ -85,12 +159,27 @@ object TraceRecorder {
rotateActiveFile(state)
}
}.onFailure {
log.error("Failed to persist trace event for path: {}", basePath, it)
log.error("Failed to persist trace event for key: {}, path: {}", event.key, basePath, it)
}
}
private fun resolveBasePath(key: String): Path {
val traceRoot = ConfigCenter.paths.stateDir.resolve("trace").normalize().toAbsolutePath()
val normalizedKey = key.trim().ifBlank { "default" }
val candidate = traceRoot.resolve(normalizedKey).normalize().toAbsolutePath()
if (candidate.startsWith(traceRoot)) {
return candidate
}
return traceRoot.resolve(sanitizeKey(normalizedKey)).normalize().toAbsolutePath()
}
private fun sanitizeKey(key: String): String {
return key.replace(Regex("[^A-Za-z0-9._-]+"), "_").trim('_').ifBlank { "default" }
}
private fun writeEvent(state: WriterState, event: TraceEvent) {
val json = JSONObject(event.payload)
json["traceKey"] = event.key
json["timestamp"] = event.timestamp
val line = json.toJSONString()
state.writer.write(line)
@@ -327,7 +416,7 @@ object TraceRecorder {
}
data class TraceEvent @JvmOverloads constructor(
val path: Path,
val key: String,
val payload: JSONObject,
val timestamp: Long = System.currentTimeMillis()
)
@@ -15,11 +15,12 @@ interface ActivateModel {
fun streamChat(
messages: List<Message>,
handler: StreamChatMessageConsumer
consumer: StreamChatMessageConsumer
): Result<Unit> {
return ModelRuntimeRegistry.resolveProvider(modelKey())
.streamChat(mergeMessages(messages), handler)
.onFailure { ExceptionReporterHandler.report(it) }
.streamChat(mergeMessages(messages), consumer)
.onSuccess { consumer.onComplete() }
.onFailure { consumer.onError(it) }
}
fun <T : Any> formattedChat(messages: List<Message>, responseType: Class<T>): Result<T> {
@@ -13,7 +13,6 @@ import work.slhaf.partner.framework.agent.model.provider.ModelProvider
import work.slhaf.partner.framework.agent.model.provider.ProviderOverride
import work.slhaf.partner.framework.agent.model.provider.openai.OpenAiCompatibleProvider
import java.nio.file.Path
import java.util.Locale.getDefault
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
@@ -86,8 +85,15 @@ object ModelRuntimeRegistry : Configurable, ConfigRegistration<ModelRuntimeRegis
override fun type(): Class<ModelRuntimeRegistryConfig> = ModelRuntimeRegistryConfig::class.java
override fun init(config: ModelRuntimeRegistryConfig, json: JSONObject?) = providerLock.withLock {
val acceptableConfig = try {
parseJsonConfig(json)
} catch (e: Exception) {
log.warn("Unable to load model config", e)
config
}
try {
applyConfig(config)
applyConfig(acceptableConfig)
} catch (e: ModelRegistryException) {
throw ModelRegistryStartupException(
e.message ?: "Failed to apply model runtime config",
@@ -104,36 +110,9 @@ object ModelRuntimeRegistry : Configurable, ConfigRegistration<ModelRuntimeRegis
val baseProviderSnapshot = baseProvider.toMap()
val runtimeProviderSnapshot = runtimeProvider.toMap()
try {
val providerSetJson = root.getJSONArray("providerConfigSet")
?: throw runtimeModelException("providerConfigSet is missing or not an array")
baseProvider.clear()
for (i in providerSetJson.indices) {
val providerJson = providerSetJson.getJSONObject(i)
?: throw runtimeModelException("providerConfigSet[$i] is not an object")
val typeText = providerJson.getString("type")
?: throw runtimeModelException(
"providerConfigSet[$i].type is missing",
providerJson.getString("name") ?: COMPONENT_NAME
)
val providerType = try {
ProviderConfig.ProviderType.valueOf(typeText.uppercase(getDefault()))
} catch (e: IllegalArgumentException) {
throw runtimeModelException(
"Unsupported provider type: $typeText",
providerJson.getString("name") ?: COMPONENT_NAME,
cause = e
)
}
val concreteProviderConfig = when (providerType) {
OPENAI_COMPATIBLE -> providerJson.toJavaObject(OpenAiCompatibleProviderConfig::class.java)
}
registerProvider(concreteProviderConfig)
}
if (!baseProvider.containsKey(DEFAULT_PROVIDER)) {
throw runtimeModelException("Provider default not found", DEFAULT_PROVIDER)
}
runtimeProvider.clear()
config.runtimeConfigSet.forEach { forkProvider(it) }
val parsedConfig = parseJsonConfig(root)
applyConfig(parsedConfig)
log.info("Model config updated")
} catch (e: Exception) {
log.error("Error while loading runtime provider config", e)
baseProvider.clear()
@@ -143,6 +122,32 @@ object ModelRuntimeRegistry : Configurable, ConfigRegistration<ModelRuntimeRegis
}
}
private fun parseJsonConfig(json: JSONObject?): ModelRuntimeRegistryConfig {
if (json == null) {
throw runtimeModelException("Unable to find model config")
}
val providerConfigSet = json.getJSONArray("providerConfigSet").filterIsInstance<JSONObject>()
.map { config ->
val type = config.getString("type")
val config = if (type.equals(OPENAI_COMPATIBLE.name.uppercase())) {
config.toJavaObject(OpenAiCompatibleProviderConfig::class.java)
} else {
null
}
if (config == null) {
throw runtimeModelException("Unknown config type: $type")
}
config
}.toSet()
val runtimeConfigSet = json.getJSONArray("runtimeConfigSet").filterIsInstance<JSONObject>()
.map { config ->
config.toJavaObject(RuntimeProviderConfig::class.java)
}.toSet()
return ModelRuntimeRegistryConfig(providerConfigSet, runtimeConfigSet)
}
override fun defaultConfig(): ModelRuntimeRegistryConfig? {
val defaultBaseUrl = System.getenv("PARTNER_DEFAULT_BASE_URL") ?: return null
val defaultApiKey = System.getenv("PARTNER_DEFAULT_API_KEY") ?: return null
@@ -1,16 +1,16 @@
package work.slhaf.partner.framework.agent.model;
public abstract class StreamChatMessageConsumer {
private final StringBuilder responseText = new StringBuilder();
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException;
public void onDelta(String delta) {
consumeDelta(delta);
responseText.append(delta);
public interface StreamChatMessageConsumer {
void onDelta(String delta);
default void onComplete() {
}
public String collectResponse() {
return responseText.toString();
default void onError(AgentRuntimeException exception) {
}
protected abstract void consumeDelta(String delta);
String collectResponse();
}
@@ -0,0 +1,114 @@
package work.slhaf.partner.framework.agent.model.provider.openai;
import java.lang.reflect.*;
import java.util.*;
final class JsonShapeInstructionBuilder {
private static final int MAX_DEPTH = 4;
private JsonShapeInstructionBuilder() {
}
static String build(Class<?> responseType) {
return "Return only a valid JSON object.\n"
+ "The JSON object must directly match this exact output shape for " + responseType.getSimpleName() + ":\n"
+ buildJsonShape(responseType, 0, new HashSet<>()) + "\n\n"
+ "Rules:\n"
+ "- The top-level object must directly match the shape above.\n"
+ "- Do not wrap it in \"" + responseType.getSimpleName() + "\" or any other class name.\n"
+ "- Do not rename fields or invent alternative field names.\n"
+ "- Do not output markdown, comments, explanations, or code fences.";
}
private static String buildJsonShape(Type type, int depth, Set<Type> visiting) {
if (depth > MAX_DEPTH) {
return "{}";
}
if (type instanceof ParameterizedType parameterizedType) {
Type rawType = parameterizedType.getRawType();
if (rawType instanceof Class<?> rawClass && Collection.class.isAssignableFrom(rawClass)) {
Type[] arguments = parameterizedType.getActualTypeArguments();
if (arguments.length == 0) {
return "[]";
}
return arrayShape(buildJsonShape(arguments[0], depth + 1, visiting), depth);
}
if (rawType instanceof Class<?> rawClass && Map.class.isAssignableFrom(rawClass)) {
return "{}";
}
return buildJsonShape(rawType, depth, visiting);
}
if (type instanceof GenericArrayType genericArrayType) {
return arrayShape(buildJsonShape(genericArrayType.getGenericComponentType(), depth + 1, visiting), depth);
}
if (!(type instanceof Class<?> clazz)) {
return "null";
}
if (clazz.isArray()) {
return arrayShape(buildJsonShape(clazz.getComponentType(), depth + 1, visiting), depth);
}
if (clazz == String.class || clazz == Character.class || clazz == char.class) {
return "\"\"";
}
if (clazz == boolean.class || clazz == Boolean.class) {
return "false";
}
if (Number.class.isAssignableFrom(clazz) || clazz.isPrimitive()) {
return "0";
}
if (clazz.isEnum()) {
Object[] constants = clazz.getEnumConstants();
return constants == null || constants.length == 0 ? "\"\"" : "\"" + constants[0] + "\"";
}
if (Collection.class.isAssignableFrom(clazz)) {
return "[]";
}
if (Map.class.isAssignableFrom(clazz)) {
return "{}";
}
if (clazz.getName().startsWith("java.")) {
return "\"\"";
}
if (visiting.contains(clazz)) {
return "{}";
}
visiting.add(clazz);
List<Field> fields = Arrays.stream(clazz.getDeclaredFields())
.filter(field -> !field.isSynthetic())
.filter(field -> !Modifier.isStatic(field.getModifiers()))
.filter(field -> !Modifier.isTransient(field.getModifiers()))
.toList();
if (fields.isEmpty()) {
visiting.remove(clazz);
return "{}";
}
StringBuilder builder = new StringBuilder();
builder.append("{\n");
for (int i = 0; i < fields.size(); i++) {
Field field = fields.get(i);
builder.append(indent(depth + 1))
.append("\"")
.append(field.getName())
.append("\": ")
.append(buildJsonShape(field.getGenericType(), depth + 1, visiting));
if (i < fields.size() - 1) {
builder.append(",");
}
builder.append("\n");
}
builder.append(indent(depth)).append("}");
visiting.remove(clazz);
return builder.toString();
}
private static String arrayShape(String itemShape, int depth) {
return "[\n" + indent(depth + 1) + itemShape.replace("\n", "\n" + indent(depth + 1)) + "\n" + indent(depth) + "]";
}
private static String indent(int depth) {
return " ".repeat(Math.max(0, depth));
}
}
@@ -1,5 +1,6 @@
package work.slhaf.partner.framework.agent.model.provider.openai;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
@@ -17,14 +18,14 @@ import work.slhaf.partner.framework.agent.model.provider.ProviderOverride;
import work.slhaf.partner.framework.agent.support.Result;
import java.time.Duration;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public class OpenAiCompatibleProvider extends ModelProvider {
private static final int MAX_ATTEMPTS = 3;
private static final ConcurrentMap<StructuredOutputCacheKey, StructuredOutputMode> STRUCTURED_OUTPUT_MODE_CACHE = new ConcurrentHashMap<>();
private final String baseUrl;
private final String apiKey;
@@ -102,13 +103,100 @@ public class OpenAiCompatibleProvider extends ModelProvider {
public <T> @NotNull Result<T> formattedChat(@NotNull List<Message> messages, @NotNull Class<T> responseType) {
return executeWithRetry(
"OpenAI-compatible provider failed to complete the structured chat request after 3 attempts.",
() -> {
StructuredChatCompletionCreateParams<T> params = buildParams(messages).toBuilder()
() -> formattedChatByCachedMode(messages, responseType)
);
}
private <T> T formattedChatByCachedMode(List<Message> messages, Class<T> responseType) {
StructuredOutputCacheKey cacheKey = new StructuredOutputCacheKey(baseUrl, model);
StructuredOutputMode mode = STRUCTURED_OUTPUT_MODE_CACHE.getOrDefault(cacheKey, StructuredOutputMode.UNKNOWN);
if (mode == StructuredOutputMode.PROMPT_ONLY_JSON) {
return promptOnlyFormattedChat(messages, responseType);
}
return strictThenPromptFallback(messages, responseType, cacheKey);
}
private <T> T strictThenPromptFallback(List<Message> messages, Class<T> responseType, StructuredOutputCacheKey cacheKey) {
try {
T result = strictFormattedChat(messages, responseType);
STRUCTURED_OUTPUT_MODE_CACHE.put(cacheKey, StructuredOutputMode.STRICT_RESPONSE_FORMAT);
return result;
} catch (Exception structuredFailure) {
try {
T result = promptOnlyFormattedChat(messages, responseType);
if (StructuredOutputFailureClassifier.shouldDowngradeToPromptOnlyJson(structuredFailure)) {
STRUCTURED_OUTPUT_MODE_CACHE.put(cacheKey, StructuredOutputMode.PROMPT_ONLY_JSON);
}
return result;
} catch (Exception fallbackFailure) {
structuredFailure.addSuppressed(fallbackFailure);
throw structuredFailure;
}
}
}
private <T> T strictFormattedChat(List<Message> messages, Class<T> responseType) {
StructuredChatCompletionCreateParams<T> params = buildParams(ensureJsonInstruction(messages, responseType)).toBuilder()
.responseFormat(responseType)
.build();
return extractStructured(client.chat().completions().create(params));
}
);
private <T> T promptOnlyFormattedChat(List<Message> messages, Class<T> responseType) {
ChatCompletionCreateParams params = buildParams(ensureJsonInstruction(messages, responseType));
String rawText = extractText(client.chat().completions().create(params));
String jsonText = extractJsonObject(rawText);
return JSON.parseObject(jsonText, responseType);
}
private List<Message> ensureJsonInstruction(List<Message> messages, Class<?> responseType) {
String jsonInstruction = JsonShapeInstructionBuilder.build(responseType);
List<Message> patched = new ArrayList<>(messages.size() + 1);
boolean merged = false;
for (Message message : messages) {
if (!merged && message.getRole() == Message.Character.SYSTEM) {
String separator = message.getContent().isBlank() ? "" : "\n\n";
patched.add(new Message(
Message.Character.SYSTEM,
message.getContent() + separator + jsonInstruction
));
merged = true;
continue;
}
patched.add(message);
}
if (!merged) {
patched.addFirst(new Message(Message.Character.SYSTEM, jsonInstruction));
}
return patched;
}
private String extractJsonObject(String text) {
String trimmed = text == null ? "" : text.trim();
if (trimmed.isBlank()) {
throw invokeException("OpenAI-compatible provider returned empty content in prompt-only JSON fallback.", null);
}
trimmed = stripMarkdownFence(trimmed);
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
return trimmed;
}
int start = trimmed.indexOf('{');
int end = trimmed.lastIndexOf('}');
if (start >= 0 && end > start) {
return trimmed.substring(start, end + 1).trim();
}
throw invokeException("OpenAI-compatible provider prompt-only JSON fallback returned no JSON object.", null);
}
private String stripMarkdownFence(String text) {
String trimmed = text.trim();
if (!trimmed.startsWith("```")) {
return trimmed;
}
String withoutOpeningFence = trimmed.replaceFirst("^```[a-zA-Z0-9_-]*\\s*", "");
return withoutOpeningFence.replaceFirst("\\s*```$", "").trim();
}
private ChatCompletionCreateParams buildParams(List<Message> messages) {
@@ -204,6 +292,15 @@ public class OpenAiCompatibleProvider extends ModelProvider {
return result;
}
private enum StructuredOutputMode {
UNKNOWN,
STRICT_RESPONSE_FORMAT,
PROMPT_ONLY_JSON
}
private record StructuredOutputCacheKey(String baseUrl, String model) {
}
@FunctionalInterface
private interface ThrowingSupplier<T> {
T get() throws Exception;
@@ -0,0 +1,87 @@
package work.slhaf.partner.framework.agent.model.provider.openai;
import java.util.*;
final class StructuredOutputFailureClassifier {
private static final List<String> TRANSIENT_FAILURE_PATTERNS = List.of(
"timeout",
"error reading response",
"streamresetexception",
"interruptedioexception",
"sockettimeoutexception",
"connectexception",
"connection reset",
"503",
"502",
"504",
"429",
"internalserverexception",
"provider returned error",
"openaiioexception",
"request failed"
);
private static final List<String> AUTH_OR_CONFIG_FAILURE_PATTERNS = List.of(
"没有权限",
"not activated",
"permission",
"unauthorized",
"forbidden",
"invalid api key",
"sslexception",
"unsupported or unrecognized ssl message"
);
private static final List<String> STRUCTURED_COMPATIBILITY_FAILURE_PATTERNS = List.of(
"response_format type is unavailable",
"messages must contain the word 'json'",
"messages must contain the word json",
"structured chat completion returned empty content",
"error parsing json:",
"unrecognizedpropertyexception",
"mismatchedinputexception",
"invalidformatexception"
);
private StructuredOutputFailureClassifier() {
}
static boolean shouldDowngradeToPromptOnlyJson(Throwable failure) {
FailureSnapshot snapshot = FailureSnapshot.from(failure);
if (snapshot.containsAny(TRANSIENT_FAILURE_PATTERNS)) {
return false;
}
if (snapshot.containsAny(AUTH_OR_CONFIG_FAILURE_PATTERNS)) {
return false;
}
return snapshot.containsAny(STRUCTURED_COMPATIBILITY_FAILURE_PATTERNS);
}
private record FailureSnapshot(String text) {
static FailureSnapshot from(Throwable failure) {
Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
StringBuilder builder = new StringBuilder();
collect(failure, builder, visited);
return new FailureSnapshot(builder.toString().toLowerCase(Locale.ROOT));
}
private static void collect(Throwable throwable, StringBuilder builder, Set<Throwable> visited) {
if (throwable == null || visited.contains(throwable)) {
return;
}
visited.add(throwable);
builder.append(throwable.getClass().getName()).append('\n');
if (throwable.getMessage() != null) {
builder.append(throwable.getMessage()).append('\n');
}
collect(throwable.getCause(), builder, visited);
for (Throwable suppressed : throwable.getSuppressed()) {
collect(suppressed, builder, visited);
}
}
boolean containsAny(List<String> patterns) {
return patterns.stream().anyMatch(text::contains);
}
}
}
@@ -33,6 +33,7 @@ object StateCenter {
}
if (!finalStatePath.exists()) {
stateRecord.saveEnabled = true
return null
}
@@ -43,18 +44,18 @@ object StateCenter {
return null
}
stateRecord.loaded = true
stateRecord.saveEnabled = true
return JSONObject.parseObject(finalStatePath.readText())
}
fun load(path: Path) {
val finalStatePath = ConfigCenter.paths.stateDir.normalize().resolve(path).normalize()
if (!stateRegistry.containsKey(path)) {
if (!stateRegistry.containsKey(finalStatePath)) {
return
}
val record = stateRegistry[finalStatePath] ?: return
record.loaded = true
record.saveEnabled = true
if (!finalStatePath.exists()) {
return
}
@@ -68,7 +69,7 @@ object StateCenter {
fun save() {
stateRegistry.forEach { (path, record) ->
if (!record.loaded) {
if (!record.saveEnabled) {
return@forEach
}
path.parent?.let(Files::createDirectories)
@@ -156,5 +157,5 @@ sealed interface StateValue {
data class StateRecord(
val serializable: StateSerializable,
var loaded: Boolean = false
var saveEnabled: Boolean = false
)
@@ -0,0 +1,79 @@
package work.slhaf.partner.framework.agent.model.provider.openai;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JsonShapeInstructionBuilderTest {
@Test
void shouldBuildShapeInstructionForComplexPojo() {
String instruction = JsonShapeInstructionBuilder.build(ComplexResponse.class);
assertTrue(instruction.startsWith("Return only a valid JSON object."));
assertTrue(instruction.contains("The JSON object must directly match this exact output shape for ComplexResponse:"));
assertTrue(instruction.contains("Do not wrap it in \"ComplexResponse\" or any other class name."));
assertTrue(instruction.contains("Do not rename fields or invent alternative field names."));
assertTrue(instruction.contains("\"id\": \"\""));
assertTrue(instruction.contains("\"success\": false"));
assertTrue(instruction.contains("\"retryCount\": 0"));
assertTrue(instruction.contains("\"score\": 0"));
assertTrue(instruction.contains("\"status\": \"READY\""));
assertTrue(instruction.contains("\"tags\": ["));
assertTrue(instruction.contains("\"items\": ["));
assertTrue(instruction.contains("\"metadata\": {}"));
assertTrue(instruction.contains("\"matrix\": ["));
assertTrue(instruction.contains("\"nested\": {"));
assertTrue(instruction.contains("\"name\": \"\""));
assertTrue(instruction.contains("\"enabled\": false"));
assertTrue(instruction.contains("\"notes\": ["));
assertFalse(instruction.contains("staticValue"));
assertFalse(instruction.contains("transientValue"));
}
@Test
void shouldPreventRecursiveExpansion() {
String instruction = JsonShapeInstructionBuilder.build(RecursiveResponse.class);
assertTrue(instruction.contains("\"name\": \"\""));
assertTrue(instruction.contains("\"next\": {}"));
}
private enum Status {
READY,
DONE
}
private static class ComplexResponse {
private static String staticValue;
private transient String transientValue;
private String id;
private boolean success;
private int retryCount;
private Double score;
private Status status;
private List<String> tags;
private List<NestedItem> items;
private Map<String, String> metadata;
private int[] matrix;
private NestedItem nested;
}
private static class NestedItem {
private String name;
private Boolean enabled;
private List<String> notes;
}
private static class RecursiveResponse {
private String name;
private RecursiveResponse next;
}
}
@@ -65,6 +65,10 @@ class ConfigCenterTest {
writeJson(workingDir.resolve(INVALID_PATH), "invalid-init", 1);
writeJson(workingDir.resolve(IDEMPOTENT_PATH), "idempotent-init", 1);
writeJson(configDir.resolve(INITIAL_PATH), "initial-config-dir", 1);
writeJson(configDir.resolve(NESTED_PATH), "nested-config-dir", 1);
writeJson(configDir.resolve(DELETE_PATH), "delete-config-dir", 1);
writeJson(configDir.resolve(INVALID_PATH), "invalid-config-dir", 1);
writeJson(configDir.resolve(IDEMPOTENT_PATH), "idempotent-config-dir", 1);
ConfigCenter.INSTANCE.register(() -> {
Map<Path, ConfigRegistration<? extends Config>> declared = new LinkedHashMap<>();
@@ -159,7 +163,7 @@ class ConfigCenterTest {
@Test
@Order(1)
void testStartOnlyInitializesOneRegisteredConfigAndDoesNotTriggerReload() {
Assertions.assertEquals(1, totalInitCount());
Assertions.assertEquals(5, totalInitCount());
Assertions.assertEquals(0, totalReloadCount());
}
@@ -0,0 +1,104 @@
package work.slhaf.partner.framework.agent.state;
import com.alibaba.fastjson2.JSONObject;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import work.slhaf.partner.framework.agent.config.ConfigCenter;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class StateCenterTest {
@SuppressWarnings("unchecked")
private static void clearRegistry() throws Exception {
Field registryField = StateCenter.class.getDeclaredField("stateRegistry");
registryField.setAccessible(true);
ConcurrentHashMap<Path, StateRecord> registry = (ConcurrentHashMap<Path, StateRecord>) registryField.get(StateCenter.INSTANCE);
registry.clear();
}
@AfterEach
void tearDown() throws Exception {
clearRegistry();
}
@Test
void shouldCreateStateFileOnFirstSaveWhenNoExistingFile() throws Exception {
Path relativePath = Path.of("state-center-test", UUID.randomUUID() + ".json");
Path finalPath = ConfigCenter.INSTANCE.getPaths().getStateDir().resolve(relativePath).normalize();
Files.deleteIfExists(finalPath);
StubStateSerializable serializable = new StubStateSerializable(relativePath, false, "fresh");
serializable.register();
StateCenter.INSTANCE.save();
assertTrue(Files.exists(finalPath));
JSONObject saved = JSONObject.parseObject(Files.readString(finalPath, StandardCharsets.UTF_8));
assertEquals("fresh", saved.getString("value"));
}
@Test
void shouldNotOverwriteExistingFileUntilManualLoadWhenAutoLoadDisabled() throws Exception {
Path relativePath = Path.of("state-center-test", UUID.randomUUID() + ".json");
Path finalPath = ConfigCenter.INSTANCE.getPaths().getStateDir().resolve(relativePath).normalize();
Files.createDirectories(finalPath.getParent());
Files.writeString(finalPath, "{\"value\":\"original\"}", StandardCharsets.UTF_8);
StubStateSerializable serializable = new StubStateSerializable(relativePath, false, "new-value");
serializable.register();
StateCenter.INSTANCE.save();
JSONObject untouched = JSONObject.parseObject(Files.readString(finalPath, StandardCharsets.UTF_8));
assertEquals("original", untouched.getString("value"));
serializable.load();
serializable.value = "after-load";
StateCenter.INSTANCE.save();
JSONObject saved = JSONObject.parseObject(Files.readString(finalPath, StandardCharsets.UTF_8));
assertEquals("after-load", saved.getString("value"));
}
private static final class StubStateSerializable implements StateSerializable {
private final Path statePath;
private final boolean autoLoadOnRegister;
private String value;
private StubStateSerializable(Path statePath, boolean autoLoadOnRegister, String value) {
this.statePath = statePath;
this.autoLoadOnRegister = autoLoadOnRegister;
this.value = value;
}
@Override
public @NotNull Path statePath() {
return statePath;
}
@Override
public void load(JSONObject state) {
value = state.getString("value");
}
@Override
public @NotNull State convert() {
State state = new State();
state.append("value", StateValue.Companion.str(value));
return state;
}
@Override
public boolean autoLoadOnRegister() {
return autoLoadOnRegister;
}
}
}
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>work.slhaf.partner</groupId>
<artifactId>partner</artifactId>
<version>0.5.0</version>
</parent>
<artifactId>partner-interaction-api</artifactId>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
@@ -1,4 +1,4 @@
package work.slhaf.partner.framework.agent.interaction.data
package work.slhaf.partner.api
open class InputData(
val source: String,
@@ -1,4 +1,4 @@
package work.slhaf.partner.framework.agent.interaction.data
package work.slhaf.partner.api
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@@ -44,13 +44,12 @@ sealed class InteractionEvent {
}
data class Reply @JvmOverloads constructor(
data class ReplyEvent @JvmOverloads constructor(
override val status: EventStatus,
override val target: String,
val content: String,
val mode: ContentMode = ContentMode.REPLACE,
val seq: Long? = null,
val done: Boolean = false
val seq: Long? = null
) : InteractionEvent() {
override val event = Event.REPLY
@@ -60,7 +59,7 @@ data class Reply @JvmOverloads constructor(
}
}
data class Module(
data class ModuleEvent(
override val status: EventStatus,
override val target: String,
val data: Data
@@ -73,7 +72,7 @@ data class Module(
)
}
data class System @JvmOverloads constructor(
data class SystemEvent @JvmOverloads constructor(
override val status: EventStatus,
override val target: String,
val title: String,
-2
View File
@@ -1,2 +0,0 @@
/mvnw text eol=lf
*.cmd text eol=crlf
-33
View File
@@ -1,33 +0,0 @@
HELP.md
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
@@ -1,3 +0,0 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
-295
View File
@@ -1,295 +0,0 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

Some files were not shown because too many files have changed in this diff Show More