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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README-zh.mbt.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ pub impl @posoco.Extension for ReadTools with fn manifest(self) -> @posoco.Exten
{
id: "posoco_ext_read",
models: [],
decisions: [],
tools: [self],
sessions: [],
observers: [],
Expand Down
1 change: 1 addition & 0 deletions README.mbt.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub impl @posoco.Extension for ReadTools with fn manifest(self) -> @posoco.Exten
{
id: "posoco_ext_read",
models: [],
decisions: [],
tools: [self],
sessions: [],
observers: [],
Expand Down
2 changes: 1 addition & 1 deletion moon.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ license = "Apache-2.0"

keywords = [ "llm", "agent", "framework", "ports-and-adapters", "ai-runtime" ]

description = "LLM Agent framework with hexagonal (ports-and-adapters) architecture. Defines 9 traits + Agent loop. Depends on moonbitlang/async."
description = "LLM Agent framework with hexagonal (ports-and-adapters) architecture. Defines extension ports + Agent loop. Depends on moonbitlang/async."

source = "src"

Expand Down
3 changes: 3 additions & 0 deletions posoco-101/02-first-agent-with-posoco.mbt.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ pub impl @posoco.Extension for FixedModel with fn manifest(self) -> @posoco.Exte
{
id: "fixed_model",
models: [self], // ← 关键:把自己放进 models 数组
decisions: [],
tools: [],
sessions: [],
observers: [],
Expand Down Expand Up @@ -207,6 +208,7 @@ pub impl @posoco.Extension for InMemoryStore with fn manifest(self) -> @posoco.E
{
id: "in_memory_store",
models: [],
decisions: [],
tools: [],
sessions: [self], // ← 贡献 SessionStore
observers: [],
Expand Down Expand Up @@ -339,6 +341,7 @@ pub impl @posoco.Extension for FailingModel with fn manifest(self) -> @posoco.Ex
{
id: "failing_model",
models: [self],
decisions: [],
tools: [],
sessions: [],
observers: [],
Expand Down
1 change: 1 addition & 0 deletions src/agent.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ fn AgentRuntime::compose(
let view = @port.CompositionView::resolve(
requires=entry.requires,
model=agg.model,
decision=agg.decision,
ui=agg.ui,
tasks=Some(task_capability),
)
Expand Down
134 changes: 134 additions & 0 deletions src/decision_port_wbtest.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
///|
/// Whitebox conformance tests for DecisionPort protocol invariants,
/// composition cardinality, gated delivery, and the scripted testkit fake.
fn decision_request() -> @port.DecisionRequest {
{
state: { "tool": "bash", "command": "git status" },
questions: [
@port.DecisionQuestion::Boolean(
id="read_only",
instructions="Does this operation only inspect state?",
true_criteria=None,
false_criteria=None,
),
],
}
}

///|
fn decision_result() -> @port.DecisionResult {
{
answers: [
@port.DecisionAnswer::BooleanAnswer(id="read_only", probability_true=0.98),
],
model: Some("scripted"),
usage: Some({ input_tokens: Some(7), output_tokens: Some(1), }),
}
}

///|
priv struct DecisionLifecycleProbe {
mut seen : Bool
}

///|
impl @port.Lifecycle for DecisionLifecycleProbe with fn on_compose(self, ctx) {
self.seen = ctx.decision() is Some(_)
}

///|
impl @port.Lifecycle for DecisionLifecycleProbe with fn on_shutdown(_self) {
()
}

///|
test "decision/request_and_result_validate" {
let request = decision_request()
let result = decision_result()
request.validate()
result.validate_for(request)
}

///|
test "decision/validation_rejects_malformed_probability" {
let request = decision_request()
let bad : @port.DecisionResult = {
answers: [
@port.DecisionAnswer::BooleanAnswer(id="read_only", probability_true=1.5),
],
model: None,
usage: None,
}
try bad.validate_for(request) catch {
@error.DecisionError::ResponseParse(_) => ()
error => fail("expected DecisionError::ResponseParse, got \{error}")
} noraise {
_ => fail("expected malformed probability to fail validation")
}
}

///|
async test "decision/testkit_records_and_replays" {
let port = ScriptedDecisionPort([Return(decision_result())])
let request = decision_request()
let result = port.evaluate_direct(request)
result.validate_for(request)
assert_eq(port.call_count(), 1)
assert_eq(port.received_requests(), [request])
}

///|
test "decision/composition_is_optional_and_gated" {
let probe_with = DecisionLifecycleProbe::{ seen: false, }
let probe_without = DecisionLifecycleProbe::{ seen: false, }
let decision = ScriptedDecisionPort([])
ignore(
Agent(
exts=[
tk_ext(
id="model",
model=Some(ScriptedModel([Respond(tk_stop_response("ok"))])),
),
tk_ext(id="io", sessions=[RecordingSessionStore()]),
tk_ext(id="decision", decision=Some(decision)),
tk_ext(id="consumer", lifecycle=[probe_with as &@port.Lifecycle], requires=[
@port.Capability::Decision,
]),
tk_ext(id="undeclared", lifecycle=[probe_without as &@port.Lifecycle]),
],
config=tk_config(),
),
)
assert_true(probe_with.seen)
assert_false(probe_without.seen)
let model : &@port.ModelPort = ScriptedModel([])
let ui : &@port.UiPort = RecordingUiPort::new_unsupported()
let absent = tk_view(requires=[@port.Capability::Decision], model~, ui~)
assert_true(absent.decision() is None)
}

///|
test "decision/composition_rejects_multiple_providers" {
let a = ScriptedDecisionPort([])
let b = ScriptedDecisionPort([])
try
Agent(
exts=[
tk_ext(
id="model",
model=Some(ScriptedModel([Respond(tk_stop_response("ok"))])),
),
tk_ext(id="io", sessions=[RecordingSessionStore()]),
tk_ext(id="decision-a", decision=Some(a)),
tk_ext(id="decision-b", decision=Some(b)),
],
config=tk_config(),
)
catch {
@error.CompositionError::MultipleDecisions(manifests~) =>
assert_eq(manifests, ["decision-a", "decision-b"])
error => fail("expected MultipleDecisions, got \{error}")
} noraise {
_ => fail("expected composition to reject multiple DecisionPorts")
}
}
37 changes: 37 additions & 0 deletions src/error/errors.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,38 @@ pub impl Show for ModelError with fn to_string(self) -> String {
}
}

///|
/// Errors raised by `DecisionPort::evaluate`. Provider adapters normalize
/// transport/protocol failures into this bounded provider-neutral surface.
pub(all) suberror DecisionError {
InvalidRequest(String)
RequestBuild(String)
Transport(String)
ResponseParse(String)
RateLimited(RateLimitInfo)
} derive(Debug)

///|
pub impl Show for DecisionError with fn to_string(self) -> String {
match self {
InvalidRequest(msg) => "DecisionError::InvalidRequest(\{msg})"
RequestBuild(msg) => "DecisionError::RequestBuild(\{msg})"
Transport(msg) => "DecisionError::Transport(\{msg})"
ResponseParse(msg) => "DecisionError::ResponseParse(\{msg})"
RateLimited(info) => {
let code_label = match info.provider_code {
Some(code) => code
None => "none"
}
let reset_label = match info.reset_at_ms {
Some(at) => "\{at}"
None => "none"
}
"DecisionError::RateLimited(code=\{code_label}, reset_at_ms=\{reset_label}, message=\{bounded_rate_limit_message(info.message)})"
}
}
}

///|
pub(all) suberror SessionError {
Load(String)
Expand Down Expand Up @@ -170,6 +202,9 @@ pub(all) suberror CompositionError {
/// must be solved inside a meta-extension (e.g. posoco-ext-llm), not by
/// declaring multiple top-level models.
MultipleModels(manifests~ : Array[String])
/// More than one extension contributed a DecisionPort. Routing among
/// decision providers belongs inside one meta-extension.
MultipleDecisions(manifests~ : Array[String])
/// Empty extension list passed to Agent::new.
EmptyManifests
/// An extension manifest was malformed (e.g. empty id, structural issue).
Expand All @@ -194,6 +229,8 @@ pub impl Show for CompositionError with fn to_string(self) -> String {
MissingModel => "CompositionError::MissingModel"
MultipleModels(manifests~) =>
"CompositionError::MultipleModels(manifests=\{manifests.join(",")})"
MultipleDecisions(manifests~) =>
"CompositionError::MultipleDecisions(manifests=\{manifests.join(",")})"
EmptyManifests => "CompositionError::EmptyManifests"
ManifestSchemaError(manifest_id~, detail~) =>
"CompositionError::ManifestSchemaError(manifest=\{manifest_id}, detail=\{detail})"
Expand Down
10 changes: 10 additions & 0 deletions src/error/pkg.generated.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,23 @@ pub(all) suberror CompositionError {
CommandCollision(String, manifests~ : Array[String])
MissingModel
MultipleModels(manifests~ : Array[String])
MultipleDecisions(manifests~ : Array[String])
EmptyManifests
ManifestSchemaError(manifest_id~ : String, detail~ : String)
EmptyPort(String)
ExtensionComposeFailed(manifest_id~ : String, detail~ : String)
} derive(@debug.Debug)
pub impl Show for CompositionError

pub(all) suberror DecisionError {
InvalidRequest(String)
RequestBuild(String)
Transport(String)
ResponseParse(String)
RateLimited(RateLimitInfo)
} derive(@debug.Debug)
pub impl Show for DecisionError

pub(all) suberror MemoryError {
Inbound(String)
Store(String)
Expand Down
16 changes: 16 additions & 0 deletions src/manifest_aggregate.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ priv struct LifecycleEntry {
/// is wrapped into one UiPort reference (the agent never sees a bare array).
priv struct AggregatedPorts {
model : &@port.ModelPort
decision : &@port.DecisionPort?
tools : Array[&@port.ToolProvider]
sessions : Array[&@port.SessionStore]
observers : Array[&@port.Observer]
Expand Down Expand Up @@ -97,6 +98,8 @@ fn aggregate_extensions(
// Per-port collectors.
let models : Array[&@port.ModelPort] = []
let model_manifests : Array[String] = []
let decisions : Array[&@port.DecisionPort] = []
let decision_manifests : Array[String] = []
let tools : Array[&@port.ToolProvider] = []
let tool_index = ToolNameIndex::new()
let sessions : Array[&@port.SessionStore] = []
Expand All @@ -122,6 +125,11 @@ fn aggregate_extensions(
models.push(m)
model_manifests.push(mid)
}
// decisions: optional singleton capability; cardinality checked below
for d in manifest.decisions {
decisions.push(d)
decision_manifests.push(mid)
}
// tools: collect + collision check by tool name
for provider in manifest.tools {
for tool_def in provider.list_tools() {
Expand Down Expand Up @@ -193,6 +201,13 @@ fn aggregate_extensions(
1 => ()
_ => raise MultipleModels(manifests=model_manifests)
}
// Decision cardinality: optional singleton. Multi-provider routing belongs
// inside one DecisionPort meta-extension.
let decision : &@port.DecisionPort? = match decisions.length() {
0 => None
1 => Some(decisions[0])
_ => raise MultipleDecisions(manifests=decision_manifests)
}
// UI cardinality: 0 → NoopUiPort, 1 → passthrough, 2+ → CompositeUiPort.
let ui_ref : &@port.UiPort = match ui.length() {
0 => (NoopUiPort() : &@port.UiPort)
Expand All @@ -201,6 +216,7 @@ fn aggregate_extensions(
}
{
model: models[0],
decision,
tools,
sessions,
observers,
Expand Down
38 changes: 36 additions & 2 deletions src/pkg.generated.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub fn tk_config() -> AgentConfig

pub fn tk_error_result(String) -> @kernel.ToolOutcome

pub fn tk_ext(id~ : String, model? : &@port.ModelPort?, tools? : Array[&@port.ToolProvider], sessions? : Array[&@port.SessionStore], observers? : Array[&@port.Observer], hooks? : Array[&@port.PipelineHook], memory? : Array[&@port.MemoryPort], lifecycle? : Array[&@port.Lifecycle], commands? : Array[&@port.CommandPort], ui? : Array[&@port.UiPort], prompt_contributors? : Array[&@port.SystemPromptContributor], requires? : Array[@port.Capability]) -> ManifestOnly
pub fn tk_ext(id~ : String, model? : &@port.ModelPort?, decision? : &@port.DecisionPort?, tools? : Array[&@port.ToolProvider], sessions? : Array[&@port.SessionStore], observers? : Array[&@port.Observer], hooks? : Array[&@port.PipelineHook], memory? : Array[&@port.MemoryPort], lifecycle? : Array[&@port.Lifecycle], commands? : Array[&@port.CommandPort], ui? : Array[&@port.UiPort], prompt_contributors? : Array[&@port.SystemPromptContributor], requires? : Array[@port.Capability]) -> ManifestOnly

pub fn tk_ok_result(String) -> @kernel.ToolOutcome

Expand All @@ -47,7 +47,7 @@ pub fn tk_tool_def(String, String) -> @kernel.ToolDef

pub fn tk_user_msg(String) -> @kernel.Message

pub fn tk_view(requires~ : Array[@port.Capability], model~ : &@port.ModelPort, ui~ : &@port.UiPort) -> @port.CompositionView
pub fn tk_view(requires~ : Array[@port.Capability], model~ : &@port.ModelPort, decision? : &@port.DecisionPort?, ui~ : &@port.UiPort) -> @port.CompositionView

pub fn validate_args(@port.CommandDef, Json) -> Result[Json, String]

Expand Down Expand Up @@ -233,6 +233,22 @@ pub fn ScopeRecordingModel::chat_scopes(Self) -> Array[@kernel.InvocationScope]
pub fn ScopeRecordingModel::compact_scopes(Self) -> Array[@kernel.InvocationScope]
pub impl @port.ModelPort for ScopeRecordingModel

pub(all) struct ScriptedDecisionPort {
steps : Array[ScriptedDecisionStep]
mut index : Int
received : Array[@port.DecisionRequest]
}
pub fn ScriptedDecisionPort::ScriptedDecisionPort(Array[ScriptedDecisionStep]) -> Self
pub fn ScriptedDecisionPort::call_count(Self) -> Int
pub async fn ScriptedDecisionPort::evaluate_direct(Self, @port.DecisionRequest) -> @port.DecisionResult raise @error.DecisionError
pub fn ScriptedDecisionPort::received_requests(Self) -> Array[@port.DecisionRequest]
pub impl @port.DecisionPort for ScriptedDecisionPort

pub(all) enum ScriptedDecisionStep {
Return(@port.DecisionResult)
Fail(@error.DecisionError)
}

pub(all) struct ScriptedMemoryPort {
inbounds : Array[String?]
mut index : Int
Expand Down Expand Up @@ -385,6 +401,22 @@ pub using @kernel {type Content}

pub using @kernel {type ContextPressure}

pub using @port {type DecisionAnswer}

pub using @error {type DecisionError}

pub using @port {type DecisionNamedProbability}

pub using @port {type DecisionOption}

pub using @port {type DecisionQuestion}

pub using @port {type DecisionRequest}

pub using @port {type DecisionResult}

pub using @port {type DecisionUsage}

pub using @types {type EventScope}

pub using @kernel {type ExecutionPolicy}
Expand Down Expand Up @@ -493,6 +525,8 @@ pub using @kernel {type Usage}

pub using @port {trait CommandPort}

pub using @port {trait DecisionPort}

pub using @port {trait Extension}

pub using @port {trait Lifecycle}
Expand Down
Loading
Loading