From 1066b3f47f53bb804d873bf1e17795255602f65e Mon Sep 17 00:00:00 2001 From: colmugx Date: Thu, 17 Sep 2026 15:13:09 +0000 Subject: [PATCH] feat: add DecisionPort semantic judgement capability --- README-zh.mbt.md | 1 + README.mbt.md | 1 + moon.mod | 2 +- posoco-101/02-first-agent-with-posoco.mbt.md | 3 + src/agent.mbt | 1 + src/decision_port_wbtest.mbt | 134 ++++++++ src/error/errors.mbt | 37 +++ src/error/pkg.generated.mbti | 10 + src/manifest_aggregate.mbt | 16 + src/pkg.generated.mbti | 38 ++- src/port/composition_view.mbt | 16 + src/port/decision.mbt | 321 +++++++++++++++++++ src/port/extension.mbt | 4 + src/port/pkg.generated.mbti | 49 ++- src/testkit_decision.mbt | 154 +++++++++ src/testkit_fixtures.mbt | 15 +- src/top.mbt | 9 + 17 files changed, 806 insertions(+), 5 deletions(-) create mode 100644 src/decision_port_wbtest.mbt create mode 100644 src/port/decision.mbt create mode 100644 src/testkit_decision.mbt diff --git a/README-zh.mbt.md b/README-zh.mbt.md index 0df9de5..b66ce6d 100644 --- a/README-zh.mbt.md +++ b/README-zh.mbt.md @@ -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: [], diff --git a/README.mbt.md b/README.mbt.md index d179826..7d27c38 100644 --- a/README.mbt.md +++ b/README.mbt.md @@ -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: [], diff --git a/moon.mod b/moon.mod index b5b4522..d72336e 100644 --- a/moon.mod +++ b/moon.mod @@ -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" diff --git a/posoco-101/02-first-agent-with-posoco.mbt.md b/posoco-101/02-first-agent-with-posoco.mbt.md index 0364737..ff9b61d 100644 --- a/posoco-101/02-first-agent-with-posoco.mbt.md +++ b/posoco-101/02-first-agent-with-posoco.mbt.md @@ -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: [], @@ -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: [], @@ -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: [], diff --git a/src/agent.mbt b/src/agent.mbt index f142c45..152cb3a 100644 --- a/src/agent.mbt +++ b/src/agent.mbt @@ -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), ) diff --git a/src/decision_port_wbtest.mbt b/src/decision_port_wbtest.mbt new file mode 100644 index 0000000..491a61f --- /dev/null +++ b/src/decision_port_wbtest.mbt @@ -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") + } +} diff --git a/src/error/errors.mbt b/src/error/errors.mbt index 61ea7ca..0c61479 100644 --- a/src/error/errors.mbt +++ b/src/error/errors.mbt @@ -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) @@ -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). @@ -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})" diff --git a/src/error/pkg.generated.mbti b/src/error/pkg.generated.mbti index 7bf4e63..ad8a9f2 100644 --- a/src/error/pkg.generated.mbti +++ b/src/error/pkg.generated.mbti @@ -31,6 +31,7 @@ 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) @@ -38,6 +39,15 @@ pub(all) suberror CompositionError { } 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) diff --git a/src/manifest_aggregate.mbt b/src/manifest_aggregate.mbt index 21c4627..8db2a19 100644 --- a/src/manifest_aggregate.mbt +++ b/src/manifest_aggregate.mbt @@ -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] @@ -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] = [] @@ -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() { @@ -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) @@ -201,6 +216,7 @@ fn aggregate_extensions( } { model: models[0], + decision, tools, sessions, observers, diff --git a/src/pkg.generated.mbti b/src/pkg.generated.mbti index 657478b..1c4105b 100644 --- a/src/pkg.generated.mbti +++ b/src/pkg.generated.mbti @@ -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 @@ -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] @@ -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 @@ -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} @@ -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} diff --git a/src/port/composition_view.mbt b/src/port/composition_view.mbt index eb5b92c..7e1e03b 100644 --- a/src/port/composition_view.mbt +++ b/src/port/composition_view.mbt @@ -14,6 +14,7 @@ /// extensions that destructure or construct views in tests. pub struct CompositionView { priv model_ : &ModelPort? + priv decision_ : &DecisionPort? priv ui_ : &UiPort? priv tasks_ : Tasks? } @@ -28,6 +29,15 @@ pub fn CompositionView::model(self : CompositionView) -> &ModelPort? { self.model_ } +///| +/// The single optional composed DecisionPort — visible only when the extension +/// declared `Capability::Decision`. Absence is legal: consumers that treat +/// decision support as an enhancement should keep their deterministic +/// fallback; consumers that require it may fail explicitly in `on_compose`. +pub fn CompositionView::decision(self : CompositionView) -> &DecisionPort? { + self.decision_ +} + ///| /// The composed UiPort (single contributor, `CompositeUiPort`, or /// `NoopUiPort`) — present only when the extension declared @@ -53,6 +63,7 @@ pub fn CompositionView::tasks(self : CompositionView) -> Tasks? { pub fn CompositionView::resolve( requires~ : Array[Capability], model~ : &ModelPort, + decision? : &DecisionPort? = None, ui~ : &UiPort, tasks? : Tasks? = None, ) -> CompositionView { @@ -62,6 +73,11 @@ pub fn CompositionView::resolve( } else { None }, + decision_: if requires.contains(Decision) { + decision + } else { + None + }, ui_: if requires.contains(Ui) { Some(ui) } else { diff --git a/src/port/decision.mbt b/src/port/decision.mbt new file mode 100644 index 0000000..39e72bf --- /dev/null +++ b/src/port/decision.mbt @@ -0,0 +1,321 @@ +///| +/// Provider-neutral semantic judgement primitives. A DecisionPort evaluates +/// structured state against independent typed questions and returns +/// probabilities; policy, authority, and execution stay with the consuming +/// extension. + +///| +/// One named alternative for a `Choice` question. +pub(all) struct DecisionOption { + id : String + description : Json? +} derive(Eq, Debug) + +///| +/// Typed question understood by a `DecisionPort`. +pub(all) enum DecisionQuestion { + /// Probability that the proposition is true. Criteria are optional outcome + /// descriptions, not policy thresholds. + Boolean( + id~ : String, + instructions~ : Json, + true_criteria~ : Json?, + false_criteria~ : Json? + ) + /// Select one named alternative and return the complete probability + /// distribution over the supplied options. + Choice(id~ : String, instructions~ : Json, options~ : Array[DecisionOption]) + /// Score against an ordered rubric. Level index is the score coordinate; + /// at least two levels are required by conforming adapters. + Score(id~ : String, instructions~ : Json, levels~ : Array[Json]) +} derive(Eq, Debug) + +///| +/// Structured state plus a non-empty set of independent semantic questions. +pub(all) struct DecisionRequest { + state : Json + questions : Array[DecisionQuestion] +} derive(Eq, Debug) + +///| +/// One named probability in a choice distribution. +pub(all) struct DecisionNamedProbability { + id : String + probability : Double +} derive(Eq, Debug) + +///| +/// Provider-neutral typed answer. The question id is repeated so adapters can +/// be validated without relying on response ordering. +pub(all) enum DecisionAnswer { + BooleanAnswer(id~ : String, probability_true~ : Double) + ChoiceAnswer( + id~ : String, + selected~ : String, + probabilities~ : Array[DecisionNamedProbability], + confidence~ : Double? + ) + ScoreAnswer( + id~ : String, + score~ : Double, + probabilities~ : Array[Double], + confidence~ : Double? + ) +} derive(Eq, Debug) + +///| +/// Optional token accounting supplied by decision providers. +pub(all) struct DecisionUsage { + input_tokens : Int? + output_tokens : Int? +} derive(Eq, Debug) + +///| +/// Typed answers plus provider-reported model/usage metadata. `model` is +/// descriptive observability only; consumers must not branch policy on a +/// provider identity hidden behind this port. +pub(all) struct DecisionResult { + answers : Array[DecisionAnswer] + model : String? + usage : DecisionUsage? +} derive(Eq, Debug) + +///| +/// A low-cost semantic judgement capability. The port does not decide final +/// business policy: consumers map the returned probabilities into their own +/// deterministic policy and authority flow. +pub(open) trait DecisionPort { + async fn evaluate(Self, request : DecisionRequest) -> DecisionResult raise @error.DecisionError +} + +///| +fn DecisionQuestion::id(self : DecisionQuestion) -> String { + match self { + Boolean(id~, ..) | Choice(id~, ..) | Score(id~, ..) => id + } +} + +///| +fn DecisionAnswer::id(self : DecisionAnswer) -> String { + match self { + BooleanAnswer(id~, ..) | ChoiceAnswer(id~, ..) | ScoreAnswer(id~, ..) => id + } +} + +///| +fn valid_probability(value : Double) -> Bool { + !value.is_nan() && !value.is_inf() && value >= 0.0 && value <= 1.0 +} + +///| +fn validate_confidence( + question_id : String, + confidence : Double?, +) -> Unit raise @error.DecisionError { + match confidence { + Some(value) if !valid_probability(value) => + raise @error.DecisionError::ResponseParse( + "confidence for question '" + + question_id + + "' must be finite and in [0,1]", + ) + _ => () + } +} + +///| +/// Validate provider-neutral request invariants before an adapter builds its +/// wire request. Providers may impose additional limits, but must not accept +/// requests that violate these base invariants. +pub fn DecisionRequest::validate( + self : DecisionRequest, +) -> Unit raise @error.DecisionError { + if self.questions.is_empty() { + raise @error.DecisionError::InvalidRequest( + "at least one decision question is required", + ) + } + let ids : Map[String, Bool] = Map::from_array([]) + for question in self.questions { + let id = question.id() + if id == "" { + raise @error.DecisionError::InvalidRequest( + "decision question id must not be empty", + ) + } + if ids.contains(id) { + raise @error.DecisionError::InvalidRequest( + "duplicate decision question id '" + id + "'", + ) + } + ids[id] = true + match question { + Boolean(..) => () + Choice(id~, options~, ..) => { + if options.length() < 2 { + raise @error.DecisionError::InvalidRequest( + "choice question '" + id + "' requires at least two options", + ) + } + let option_ids : Map[String, Bool] = Map::from_array([]) + for option in options { + if option.id == "" { + raise @error.DecisionError::InvalidRequest( + "choice question '" + id + "' has an empty option id", + ) + } + if option_ids.contains(option.id) { + raise @error.DecisionError::InvalidRequest( + "choice question '" + + id + + "' has duplicate option '" + + option.id + + "'", + ) + } + option_ids[option.id] = true + } + } + Score(id~, levels~, ..) => + if levels.length() < 2 { + raise @error.DecisionError::InvalidRequest( + "score question '" + id + "' requires at least two levels", + ) + } + } + } +} + +///| +/// Validate a provider result against the request that produced it. Adapters +/// should call this before returning so malformed or semantically mismatched +/// provider payloads fail loudly as `DecisionError::ResponseParse`. +pub fn DecisionResult::validate_for( + self : DecisionResult, + request : DecisionRequest, +) -> Unit raise @error.DecisionError { + request.validate() + if self.answers.length() != request.questions.length() { + raise @error.DecisionError::ResponseParse( + "decision answer count does not match question count", + ) + } + let questions : Map[String, DecisionQuestion] = Map::from_array([]) + for question in request.questions { + questions[question.id()] = question + } + let seen : Map[String, Bool] = Map::from_array([]) + for answer in self.answers { + let id = answer.id() + if seen.contains(id) { + raise @error.DecisionError::ResponseParse( + "duplicate decision answer id '" + id + "'", + ) + } + seen[id] = true + if !questions.contains(id) { + raise @error.DecisionError::ResponseParse( + "decision answer id '" + id + "' was not requested", + ) + } + match (questions[id], answer) { + (Boolean(..), BooleanAnswer(probability_true~, ..)) => + if !valid_probability(probability_true) { + raise @error.DecisionError::ResponseParse( + "boolean probability for question '" + + id + + "' must be finite and in [0,1]", + ) + } + ( + Choice(options~, ..), + ChoiceAnswer(selected~, probabilities~, confidence~, ..), + ) => { + validate_confidence(id, confidence) + if probabilities.length() != options.length() { + raise @error.DecisionError::ResponseParse( + "choice probabilities for question '" + + id + + "' do not match option count", + ) + } + let allowed : Map[String, Bool] = Map::from_array([]) + for option in options { + allowed[option.id] = true + } + if !allowed.contains(selected) { + raise @error.DecisionError::ResponseParse( + "choice answer for question '" + + id + + "' selected unknown option '" + + selected + + "'", + ) + } + let probability_ids : Map[String, Bool] = Map::from_array([]) + let mut sum = 0.0 + for entry in probabilities { + if !allowed.contains(entry.id) || probability_ids.contains(entry.id) { + raise @error.DecisionError::ResponseParse( + "choice probabilities for question '" + + id + + "' contain an unknown or duplicate option", + ) + } + if !valid_probability(entry.probability) { + raise @error.DecisionError::ResponseParse( + "choice probability for question '" + + id + + "' must be finite and in [0,1]", + ) + } + probability_ids[entry.id] = true + sum = sum + entry.probability + } + if (sum - 1.0).abs() > 0.000001 { + raise @error.DecisionError::ResponseParse( + "choice probabilities for question '" + id + "' must sum to 1", + ) + } + } + (Score(levels~, ..), ScoreAnswer(score~, probabilities~, confidence~, ..)) => { + validate_confidence(id, confidence) + if probabilities.length() != levels.length() { + raise @error.DecisionError::ResponseParse( + "score probabilities for question '" + + id + + "' do not match level count", + ) + } + if score.is_nan() || + score.is_inf() || + score < 0.0 || + score > (levels.length() - 1).to_double() { + raise @error.DecisionError::ResponseParse( + "score for question '" + id + "' is outside the rubric range", + ) + } + let mut sum = 0.0 + for probability in probabilities { + if !valid_probability(probability) { + raise @error.DecisionError::ResponseParse( + "score probability for question '" + + id + + "' must be finite and in [0,1]", + ) + } + sum = sum + probability + } + if (sum - 1.0).abs() > 0.000001 { + raise @error.DecisionError::ResponseParse( + "score probabilities for question '" + id + "' must sum to 1", + ) + } + } + _ => + raise @error.DecisionError::ResponseParse( + "decision answer kind does not match question '" + id + "'", + ) + } + } +} diff --git a/src/port/extension.mbt b/src/port/extension.mbt index 8be0721..4075e37 100644 --- a/src/port/extension.mbt +++ b/src/port/extension.mbt @@ -14,6 +14,8 @@ pub(all) enum Capability { /// The single composed ModelPort (post cardinality gate, source-agnostic). Model + /// The single optional composed DecisionPort for semantic judgement. + Decision /// The composed UiPort (single / Composite / Noop), for human interaction. Ui /// The Agent-owned structured task capability. @@ -31,6 +33,7 @@ pub(all) enum Capability { pub(all) struct ExtensionManifest { id : String models : Array[&ModelPort] + decisions : Array[&DecisionPort] tools : Array[&ToolProvider] sessions : Array[&SessionStore] observers : Array[&Observer] @@ -54,6 +57,7 @@ pub fn ExtensionManifest::empty(id~ : String) -> ExtensionManifest { { id, models: [], + decisions: [], tools: [], sessions: [], observers: [], diff --git a/src/port/pkg.generated.mbti b/src/port/pkg.generated.mbti index 994233b..dec9499 100644 --- a/src/port/pkg.generated.mbti +++ b/src/port/pkg.generated.mbti @@ -32,6 +32,7 @@ pub(all) struct AutocompleteSource { pub(all) enum Capability { Model + Decision Ui Tasks } derive(Eq, @debug.Debug) @@ -78,14 +79,56 @@ pub(all) enum CommandType { pub struct CompositionView { // private fields } +pub fn CompositionView::decision(Self) -> &DecisionPort? pub fn CompositionView::model(Self) -> &ModelPort? -pub fn CompositionView::resolve(requires~ : Array[Capability], model~ : &ModelPort, ui~ : &UiPort, tasks? : Tasks?) -> Self +pub fn CompositionView::resolve(requires~ : Array[Capability], model~ : &ModelPort, decision? : &DecisionPort?, ui~ : &UiPort, tasks? : Tasks?) -> Self pub fn CompositionView::tasks(Self) -> Tasks? pub fn CompositionView::ui(Self) -> &UiPort? +pub(all) enum DecisionAnswer { + BooleanAnswer(id~ : String, probability_true~ : Double) + ChoiceAnswer(id~ : String, selected~ : String, probabilities~ : Array[DecisionNamedProbability], confidence~ : Double?) + ScoreAnswer(id~ : String, score~ : Double, probabilities~ : Array[Double], confidence~ : Double?) +} derive(Eq, @debug.Debug) + +pub(all) struct DecisionNamedProbability { + id : String + probability : Double +} derive(Eq, @debug.Debug) + +pub(all) struct DecisionOption { + id : String + description : Json? +} derive(Eq, @debug.Debug) + +pub(all) enum DecisionQuestion { + Boolean(id~ : String, instructions~ : Json, true_criteria~ : Json?, false_criteria~ : Json?) + Choice(id~ : String, instructions~ : Json, options~ : Array[DecisionOption]) + Score(id~ : String, instructions~ : Json, levels~ : Array[Json]) +} derive(Eq, @debug.Debug) + +pub(all) struct DecisionRequest { + state : Json + questions : Array[DecisionQuestion] +} derive(Eq, @debug.Debug) +pub fn DecisionRequest::validate(Self) -> Unit raise @error.DecisionError + +pub(all) struct DecisionResult { + answers : Array[DecisionAnswer] + model : String? + usage : DecisionUsage? +} derive(Eq, @debug.Debug) +pub fn DecisionResult::validate_for(Self, DecisionRequest) -> Unit raise @error.DecisionError + +pub(all) struct DecisionUsage { + input_tokens : Int? + output_tokens : Int? +} derive(Eq, @debug.Debug) + pub(all) struct ExtensionManifest { id : String models : Array[&ModelPort] + decisions : Array[&DecisionPort] tools : Array[&ToolProvider] sessions : Array[&SessionStore] observers : Array[&Observer] @@ -257,6 +300,10 @@ pub(open) trait CommandPort { async fn invoke(Self, String, Json) -> CommandOutcome raise @error.CommandError } +pub(open) trait DecisionPort { + async fn evaluate(Self, DecisionRequest) -> DecisionResult raise @error.DecisionError +} + pub(open) trait Extension { fn extension_id(Self) -> String fn manifest(Self) -> ExtensionManifest diff --git a/src/testkit_decision.mbt b/src/testkit_decision.mbt new file mode 100644 index 0000000..b520712 --- /dev/null +++ b/src/testkit_decision.mbt @@ -0,0 +1,154 @@ +// --------------------------------------------------------------------------- +// ScriptedDecisionPort — fixed semantic-judgement script + request recording. +// --------------------------------------------------------------------------- + +///| +pub(all) enum ScriptedDecisionStep { + Return(@port.DecisionResult) + Fail(@error.DecisionError) +} + +///| +fn snapshot_optional_json(value : Json?) -> Json? { + match value { + Some(value) => Some(snapshot_json(value)) + None => None + } +} + +///| +fn snapshot_decision_question( + question : @port.DecisionQuestion, +) -> @port.DecisionQuestion { + match question { + Boolean(id~, instructions~, true_criteria~, false_criteria~) => + Boolean( + id~, + instructions=snapshot_json(instructions), + true_criteria=snapshot_optional_json(true_criteria), + false_criteria=snapshot_optional_json(false_criteria), + ) + Choice(id~, instructions~, options~) => + Choice( + id~, + instructions=snapshot_json(instructions), + options=options.map(fn(option) { + { + id: option.id, + description: snapshot_optional_json(option.description), + } + }), + ) + Score(id~, instructions~, levels~) => + Score( + id~, + instructions=snapshot_json(instructions), + levels=levels.map(snapshot_json), + ) + } +} + +///| +fn snapshot_decision_request( + request : @port.DecisionRequest, +) -> @port.DecisionRequest { + { + state: snapshot_json(request.state), + questions: request.questions.map(snapshot_decision_question), + } +} + +///| +fn snapshot_decision_answer( + answer : @port.DecisionAnswer, +) -> @port.DecisionAnswer { + match answer { + BooleanAnswer(id~, probability_true~) => + BooleanAnswer(id~, probability_true~) + ChoiceAnswer(id~, selected~, probabilities~, confidence~) => + ChoiceAnswer( + id~, + selected~, + probabilities=probabilities.map(fn(entry) { + { id: entry.id, probability: entry.probability, } + }), + confidence~, + ) + ScoreAnswer(id~, score~, probabilities~, confidence~) => + ScoreAnswer(id~, score~, probabilities=probabilities.copy(), confidence~) + } +} + +///| +fn snapshot_decision_result( + result : @port.DecisionResult, +) -> @port.DecisionResult { + { + answers: result.answers.map(snapshot_decision_answer), + model: result.model, + usage: result.usage, + } +} + +///| +fn snapshot_decision_step(step : ScriptedDecisionStep) -> ScriptedDecisionStep { + match step { + Return(result) => Return(snapshot_decision_result(result)) + Fail(error) => Fail(error) + } +} + +///| +/// DecisionPort fake that consumes one scripted step per evaluation and +/// records a deep snapshot of every request. Exhaustion is loud. +pub(all) struct ScriptedDecisionPort { + steps : Array[ScriptedDecisionStep] + mut index : Int + received : Array[@port.DecisionRequest] +} + +///| +pub fn ScriptedDecisionPort::ScriptedDecisionPort( + steps : Array[ScriptedDecisionStep], +) -> ScriptedDecisionPort { + { steps: steps.map(snapshot_decision_step), index: 0, received: [], } +} + +///| +pub fn ScriptedDecisionPort::call_count(self : ScriptedDecisionPort) -> Int { + self.received.length() +} + +///| +pub fn ScriptedDecisionPort::received_requests( + self : ScriptedDecisionPort, +) -> Array[@port.DecisionRequest] { + self.received.map(snapshot_decision_request) +} + +///| +pub async fn ScriptedDecisionPort::evaluate_direct( + self : ScriptedDecisionPort, + request : @port.DecisionRequest, +) -> @port.DecisionResult raise @error.DecisionError { + (self as &@port.DecisionPort).evaluate(request) +} + +///| +pub impl @port.DecisionPort for ScriptedDecisionPort with fn evaluate( + self, + request, +) -> @port.DecisionResult raise @error.DecisionError { + self.received.push(snapshot_decision_request(request)) + if self.index >= self.steps.length() { + raise @error.DecisionError::Transport( + "scripted_decision_exhausted at call \{self.received.length()} (script had \{self.steps.length()} steps)", + ) + } + let step = self.steps[self.index] + self.index = self.index + 1 + match step { + Return(result) => snapshot_decision_result(result) + Fail(error) => raise error + } +} diff --git a/src/testkit_fixtures.mbt b/src/testkit_fixtures.mbt index 6612265..71046cb 100644 --- a/src/testkit_fixtures.mbt +++ b/src/testkit_fixtures.mbt @@ -311,6 +311,7 @@ pub impl @port.MemoryPort for ScriptedMemoryPort with fn delete( pub fn tk_ext( id~ : String, model? : &@port.ModelPort? = None, + decision? : &@port.DecisionPort? = None, tools? : Array[&@port.ToolProvider] = [], sessions? : Array[&@port.SessionStore] = [], observers? : Array[&@port.Observer] = [], @@ -326,9 +327,14 @@ pub fn tk_ext( Some(m) => [m] None => [] } + let decisions : Array[&@port.DecisionPort] = match decision { + Some(d) => [d] + None => [] + } ManifestOnly({ id, models, + decisions, tools, sessions, observers, @@ -350,12 +356,19 @@ pub fn tk_ext( pub fn tk_view( requires~ : Array[@port.Capability], model~ : &@port.ModelPort, + decision? : &@port.DecisionPort? = None, ui~ : &@port.UiPort, ) -> @port.CompositionView { let tasks = @port.Tasks::from_submit(submit=fn(_) { Err(@port.TaskSubmitError::Unavailable) }) - @port.CompositionView::resolve(requires~, model~, ui~, tasks=Some(tasks)) + @port.CompositionView::resolve( + requires~, + model~, + decision~, + ui~, + tasks=Some(tasks), + ) } ///| diff --git a/src/top.mbt b/src/top.mbt index 062e602..382d273 100644 --- a/src/top.mbt +++ b/src/top.mbt @@ -19,6 +19,7 @@ pub using @types { // Core port traits. pub using @port { trait ModelPort, + trait DecisionPort, trait ToolProvider, trait SessionStore, trait Observer, @@ -38,12 +39,19 @@ pub using @port { type ParamType, type HookStage, type ToolDecision, + type DecisionQuestion, + type DecisionAnswer, type UiHint, type UiSlot, type UiBody, type UiRequest, type UiResponse, // Port-related structs. + type DecisionOption, + type DecisionRequest, + type DecisionNamedProbability, + type DecisionUsage, + type DecisionResult, type CommandDef, type AutocompleteItem, type AutocompleteSource, @@ -70,6 +78,7 @@ pub using @port { pub using @error { type AgentError, type ModelError, + type DecisionError, type RateLimitInfo, type RuntimeError, type SessionError,