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
32 changes: 32 additions & 0 deletions docs/reference/goal-acceptance-observations.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,29 @@ check consumes real status collection over a disposable synthetic Goal; set

## Default supporting outcomes

Creating a Goal with a selected Agent and explicit **Completion criteria** now
retains that reviewed field as a supporting outcome before the first Session
can start. The App and the typed `goal.create` action share this path. Completing
the initial Todos does not erase the criteria: unmet requirements return to the
existing Goal frontier and replan owner. No acceptance-check setup or extra
continuation switch is needed. Criteria without a selected Agent are refused
before creation; an objective alone is not parsed into invented requirements.

Creation recovery keeps the original declaration operation and provider revision
through a lost response. A known revision conflict holds the creation for
reconciliation instead of silently rebasing it or starting a Host. Already
dispatched historical creations retain their original semantics; this is not a
retroactive migration. Stop, permissions, available budget, independent checks
and natural native Goal completion retain their existing owners.

中文:创建 Goal 时,已选择执行 Agent 并明确填写的“完成标准”会在首个会话启动前
保存为持续要求。App 与 `goal.create` 共用这条路径;首批 Todo 做完后,尚未满足的
要求仍进入现有 Goal frontier 和重规划流程,无需另开续跑开关或配置检查命令。
未选择 Agent 时先拒绝创建;系统不会从自由文本目标中猜出新要求。响应丢失沿用原
操作和 provider revision 恢复;已知版本冲突先保留现场,不自动重基或启动模型。
已经启动过会话的旧创建记录保持原义,不自动迁移。停止、权限、预算、独立检查及
原生 Goal 的自然结束规则保持有效。

Goal Overview shows supporting outcomes recorded by a bound Agent: original
objective, cumulative requirements, linked work, sources and separate Agent
review/check results. The local owner can **Add a requirement** and read the
Expand Down Expand Up @@ -141,6 +164,15 @@ zero retained review bytes. These are storage observations, not model tokens, pa
64-source limit per outcome remains independent. Omitting `source` on an
ordinary review avoids adding provenance but cannot bypass either byte budget.

A requirement description permits 2000 UTF-16 code units so the existing
creation field's full 1000 Unicode code points, including supplementary-plane
characters, survive without clipping. Previously its 512-unit bound rejected
that already supported input. This field alignment does not enlarge either
131072-byte aggregate envelope, source count, outcome count or execution budget;
oversized declarations still fail atomically. 中文:单条要求的描述上限对齐创建表单已有的
1000 个 Unicode 码点(最多 2000 个 UTF-16 单元),保留完整确认文本;两个聚合字节
预算及其他容量、执行预算均不扩大,超限仍原子拒绝。

For review overflow, shorten only the proposed review notes and evidence
references while preserving each conclusion and its provenance; keep full
evidence in its owning material. For declaration overflow, shortening reviews
Expand Down
2 changes: 2 additions & 0 deletions loopx/chat_action_normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,8 @@ def _normalize(
for field in ("agent_id", "workspace_ref"):
if values.get(field):
result[field] = _opaque(values[field], field=field)
if result.get("completion_criteria") and not result.get("agent_id"):
raise ValueError("completion_criteria require the Goal's selected Agent")
if result.get("agent_id"):
# A creation's Agent is refused as a typed gate, so a fresh install
# learns which Agent is missing instead of reading a protocol sentence.
Expand Down
26 changes: 26 additions & 0 deletions loopx/chat_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,32 @@ def require_first_turn_readback(turn: dict[str, Any]) -> None:
step="todos_created",
receipt={"outcome": "todos_created", "todo_ids": todo_ids},
)
completion_plan = effect_runtime_result("presentation.action_review_plan.compile", {
"proposal": self.store.load(proposal_id),
})
completion = completion_plan.get("creationOutcome")
completion_progress = completion_plan.get("creationProgress") or {}
if completion and not completion_progress.get("completionRecorded"):
from .control_plane.goals.acceptance import inspect_goal_acceptance, refine_goal_outcome

basis = completion_progress.get("completionBasis")
if basis is None:
current = inspect_goal_acceptance(registry_path=self.registry_path,
runtime_root=str(runtime_root), goal_id=goal_id, agent_id=agent_id)
basis = {"providerRevision": current["provider_revision"], "operationId": completion["operationId"]}
# Persist the original CAS before sending the canonical write.
# An uncertain response must replay this request, not new intent.
self.store.save_checkpoint(proposal_id, step="goal_completion_prepared", receipt={
"outcome": "goal_completion_prepared", "goal_id": goal_id,
"operation_id": basis["operationId"], "provider_revision": basis["providerRevision"],
})
retained = refine_goal_outcome(registry_path=self.registry_path, runtime_root=str(runtime_root),
goal_id=goal_id, agent_id=agent_id, refinement=completion["refinement"],
operation_id=basis["operationId"], expected_provider_revision=basis["providerRevision"], execute=True)
self.store.save_checkpoint(proposal_id, step="goal_completion_recorded", receipt={
"outcome": "goal_completion_recorded", "goal_id": goal_id,
"operation_id": basis["operationId"], "provider_revision": retained["provider_revision"],
})
projected = self._goal(goal_id)
if agent_id and agent_id not in registered_agent_ids_for_goal(projected):
raise ValueError(
Expand Down
22 changes: 22 additions & 0 deletions loopx/control_plane/goals/creation_outcome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** Adapt an owner's approved creation fields to the existing outcome owner.
* No prose interpretation, check activation, completion or scheduling policy. */
export type GoalCreationOutcome = {
operationId: string;
refinement: {
outcome_id: string; owner_agent_id: string; objective: string;
requirements: Array<{id: string; description: string}>;
todo_ids: string[]; source: {ref: string; summary: string};
};
};

export function goalCreationOutcome(proposalId: string, parameters: Record<string, unknown>,
todoIds: string[]): GoalCreationOutcome | undefined {
const criteria = parameters.completion_criteria, agent = parameters.agent_id;
if (typeof criteria !== "string" || !criteria.trim() || typeof agent !== "string" || !agent.trim()) return undefined;
const operationId = `goal-create:${proposalId}:completion`;
return {operationId, refinement: {
outcome_id: "goal-creation", owner_agent_id: agent, objective: String(parameters.title),
requirements: [{id: "completion", description: criteria}], todo_ids: todoIds,
source: {ref: operationId, summary: "Owner-approved Goal creation completion criteria"},
}};
}
4 changes: 3 additions & 1 deletion loopx/control_plane/goals/outcome_requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ function unique(values: string[], label: string): void {requireValue(new Set(val
function requirements(value: unknown): Requirement[] {
const result = list(value, "requirements", 32).map(value => {
const row = canonicalAuthorityObject(value, "requirement"); keys(row, ["id", "description"]);
return {id: id(row.id, "requirement id"), description: text(row.description, "requirement description")};
// Goal creation accepts 1,000 Unicode code points (up to 2,000 UTF-16
// units). Keep that reviewed text intact; aggregate byte limits still hold.
return {id: id(row.id, "requirement id"), description: text(row.description, "requirement description", 2000)};
});
unique(result.map(row => row.id), "requirements"); return result;
}
Expand Down
26 changes: 26 additions & 0 deletions loopx/control_plane/presentation/action_review_plan.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {DecisionOutcome, ResumeState} from "../todos/user_completion_types.js";
import {BARE_SHA256_PATTERN} from "../content_digest.ts";
import {goalCreationOutcome, type GoalCreationOutcome} from "../goals/creation_outcome.ts";

export type ActionReviewIdentity = {
schemaVersion: "action_review_plan_v0";
Expand Down Expand Up @@ -94,13 +95,16 @@ export type ActionReviewPlan = ActionReviewIdentity & ActionReviewState & {
retryOriginal?: true;
/** Recorded creation effects, not evidence of a completed Goal or model Turn. */
creationProgress?: GoalCreationProgress;
creationOutcome?: GoalCreationOutcome;
};

export type GoalCreationProgress = {
workspaceDigest: string;
goalId?: string;
agentId?: string;
todoIds?: string[];
completionBasis?: {operationId: string; providerRevision: string};
completionRecorded?: true;
sessionId?: string;
firstTurn?: {sessionId: string; turnId: string};
};
Expand Down Expand Up @@ -137,6 +141,22 @@ function goalCreationProgress(proposal: Record<string, unknown>): GoalCreationPr
|| !ids.every(id => textValue(id) !== null) || new Set(ids).size !== ids.length) return undefined;
progress.todoIds = ids as string[];
}
const completion = objectValue(steps?.goal_completion_prepared);
const recorded = objectValue(steps?.goal_completion_recorded);
if (steps?.goal_completion_prepared != null) {
const declaration = goalCreationOutcome(String(proposal.proposal_id), parameters!, progress.todoIds ?? []);
const providerRevision = textValue(completion?.provider_revision);
if (!progress.todoIds || !declaration || completion?.outcome !== "goal_completion_prepared"
|| completion.goal_id !== goalId || completion.operation_id !== declaration.operationId || !providerRevision) return undefined;
progress.completionBasis = {operationId: declaration.operationId, providerRevision};
}
if (steps?.goal_completion_recorded != null) {
if (!progress.completionBasis || recorded?.outcome !== "goal_completion_recorded"
|| recorded.goal_id !== goalId || recorded.operation_id !== progress.completionBasis.operationId
|| !textValue(recorded.provider_revision)) return undefined;
progress.completionRecorded = true;
}
if ((session || turn) && progress.completionBasis && !progress.completionRecorded) return undefined;
if (steps?.first_turn_started != null) {
const sessionId = textValue(turn?.session_id), turnId = textValue(turn?.turn_id);
if (!progress.agentId || !progress.todoIds || turn?.outcome !== "first_turn_started" || !sessionId || !turnId
Expand Down Expand Up @@ -502,13 +522,19 @@ export function compileActionReviewPlan(proposalValue: unknown, nowMs?: number):
const reviewCardFrame = compileReviewCardFrame(proposal);
const decisionFrame = compileDecisionReviewFrame(proposal);
const creationProgress = goalCreationProgress(proposal);
// Older already-dispatched creations retain their original semantics. A
// failed write retries its original CAS even after a response was lost.
const creationOutcome = creationProgress?.todoIds && (!creationProgress.sessionId && !creationProgress.firstTurn
|| creationProgress.completionBasis)
? goalCreationOutcome(identity.proposalId, objectValue(proposal.normalized_parameters) ?? {}, creationProgress.todoIds) : undefined;
const finish = (state: ActionReviewState): ActionReviewPlan => ({
...identity,
...state,
...(operationFrame ? { operationFrame } : {}),
...(reviewCardFrame ? { reviewCardFrame } : {}),
...(decisionFrame ? { decisionFrame } : {}),
...(creationProgress ? { creationProgress } : {}),
...(creationOutcome ? { creationOutcome } : {}),
});
const held = (
interaction: "gated" | "refresh" | "repair" | "pending" | "completed" | "inactive",
Expand Down
Loading
Loading