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
56 changes: 56 additions & 0 deletions ts/docs/architecture/core/dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,62 @@ entity references. Named entities (e.g., "that song", "the meeting") are
looked up in conversation memory. Ambiguous references trigger a user
clarification prompt via the `ClientIO` layer.

**Intermediate results** - A translated `resultEntityId` labels a completed
action; it does not require that every successful action manufacture an
entity. The three consumers have distinct contracts:

- A legacy `${result-id}` parameter consumes `resultEntity.name` and retains
same-agent entity metadata. A missing entity remains an error.
- A `{ "$result": "id" }` parameter consumes only the explicit `resultValue`,
not display text, structured display `rawData`, or an entity name. Before
invoking the consumer, the dispatcher validates the concrete value against
its parameter schema without the translation-time placeholder exemption.
Empty strings, empty arrays, zero, and false are values, not missing results.
- A `pendingRequestAction` remains in the execution queue until its earlier
action completes. Translation receives request-local snapshots of completed
actions and their actual results, including display-only outputs, even when
conversation history or memory extraction is disabled. These outputs are
context for translating the remaining request, not instructions to replay
earlier actions or an automatic switch to reasoning.

Deferred context uses a projection of result values, entity bindings, history
text, and display data, rather than serializing execution metadata. Identical
value/text representations and duplicate structured `rawData` are omitted;
display alternates and presentation flags are not sent. Distinct display
content is preserved even when history text is only a summary. Entity metadata
continues to be available through the history's entity references.

The serialized UTF-8 envelope containing the remaining request and its full
history context is limited to 64 KiB. The limit includes all completed outputs,
action parameters, inherited prompt sections, entities, activity state, and
additional instructions. This is a deterministic deferred-context safeguard,
not a token limit for the complete model prompt (which also includes schemas
and other translation instructions). Oversized context stops before translating
or executing the continuation, with an explicit error: no output is silently
truncated or summarized and completed producers are not replayed. Concrete
`$result` substitution is unchanged and does not use this prompt-size limit.

Deferred translation retains the caller's active-schema and schema-family
restrictions. An unavailable or empty scope stops the continuation rather than
widening it to globally active schemas. Newly translated actions also pass the
execution-eligibility check before entering the queue; unknown or disabled
actions stop the continuation without reasoning fallback or producer replay.

An unused result label does not turn a successful mutation into a failure.
Errors stop the chain; missing references and invalid concrete values fail
before their consumers execute. Deferred translation cannot use an action
still awaiting confirmation. Continuations retain completed-action history
while each newly translated plan has its own result-reference bindings.

In legacy action execution, a pending user choice stops the remaining queue,
including actions without result references and any returned additional
actions. The choice remains available, but the dispatcher explicitly reports
that the remaining steps were not executed and will not resume automatically.
This interruption does not trigger reasoning fallback or replay completed
actions. A standalone choice retains its existing behavior. Structured
execution continues to resolve choices through its own awaited interaction
path before returning to the action queue.

Translated actions may also contain **entity placeholders** — explicit
references the LLM emits as string values pointing back at entities
provided in the prompt's history context. `resolveEntityPlaceholders()`
Expand Down
41 changes: 35 additions & 6 deletions ts/packages/actionSchema/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,14 @@ export function validateSchema(
expected: SchemaType,
actual: unknown,
coerce: boolean = false, // coerce string to the right primitive type
allowResultReferences: boolean = true,
) {
if (actual === null) {
throw new Error(`${errorName(name)} should not be null`);
}
// A result-reference placeholder ({ "$result": "<id>" }) is resolved to its
// real value at execution time, so accept it against any expected type.
if (isResultReference(actual)) {
if (allowResultReferences && isResultReference(actual)) {
return;
}
switch (expected.type) {
Expand All @@ -59,7 +60,13 @@ export function validateSchema(
const errors: [SchemaType, Error][] = [];
for (const type of expected.types) {
try {
return validateSchema(name, type, actual, coerce);
return validateSchema(
name,
type,
actual,
coerce,
allowResultReferences,
);
} catch (e: any) {
errors.push([type, e]);
}
Expand All @@ -82,6 +89,7 @@ export function validateSchema(
expected.definition.type,
actual,
coerce,
allowResultReferences,
);
}
break;
Expand All @@ -96,6 +104,8 @@ export function validateSchema(
expected,
actual as Record<string, unknown>,
coerce,
undefined,
allowResultReferences,
);
break;
case "array":
Expand All @@ -104,7 +114,13 @@ export function validateSchema(
`${errorName(name)} is not an array, got ${typeof actual} instead`,
);
}
validateArray(name, expected, actual, coerce);
validateArray(
name,
expected,
actual,
coerce,
allowResultReferences,
);
break;
case "string-union":
if (typeof actual !== "string") {
Expand Down Expand Up @@ -154,6 +170,7 @@ function validateArray(
expected: SchemaTypeArray,
actual: unknown[],
coerce: boolean = false,
allowResultReferences: boolean = true,
) {
for (let i = 0; i < actual.length; i++) {
const element = actual[i];
Expand All @@ -162,6 +179,7 @@ function validateArray(
expected.elementType,
element,
coerce,
allowResultReferences,
);
if (coerce && v !== undefined) {
actual[i] = v;
Expand All @@ -175,6 +193,7 @@ function validateObject(
actual: Record<string, unknown>,
coerce: boolean,
ignoreExtraneous?: string[],
allowResultReferences: boolean = true,
) {
for (const field of Object.entries(expected.fields)) {
const [fieldName, fieldInfo] = field;
Expand All @@ -186,7 +205,13 @@ function validateObject(
}
continue;
}
const v = validateSchema(fullName, fieldInfo.type, actualValue, coerce);
const v = validateSchema(
fullName,
fieldInfo.type,
actualValue,
coerce,
allowResultReferences,
);
if (coerce && v !== undefined) {
actual[fieldName] = v;
}
Expand All @@ -211,6 +236,10 @@ export function validateAction(
validateObject("", actionSchema.type, action, coerce, ["schemaName"]);
}

export function validateType(type: SchemaType, value: any) {
validateSchema("", type, value);
export function validateType(
type: SchemaType,
value: unknown,
allowResultReferences: boolean = true,
) {
validateSchema("", type, value, false, allowResultReferences);
}
34 changes: 33 additions & 1 deletion ts/packages/actionSchema/test/validate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import * as sc from "../src/creator.js";
import { SchemaType } from "../src/type.js";
import { validateSchema } from "../src/validate.js";
import { validateSchema, validateType } from "../src/validate.js";

const fields: sc.FieldSpec = { a: sc.string(), b: sc.optional(sc.number()) };
const obj = sc.obj(fields);
Expand Down Expand Up @@ -192,6 +192,38 @@ describe("result reference placeholder", () => {
validateSchema("param", schema, ref);
});

it.each(schemas)(
"rejected as a concrete result against %s",
(_name, schema) => {
expect(() => validateType(schema, ref, false)).toThrow();
},
);

it.each([
[sc.array(sc.string()), [ref]],
[sc.obj({ value: sc.string() }), { value: ref }],
[sc.union(sc.string(), sc.number()), ref],
[sc.ref(sc.type("StringValue", sc.string())), ref],
] as [SchemaType, unknown][])(
"validates nested concrete results without the translation placeholder exemption",
(schema, value) => {
expect(() => validateType(schema, value)).not.toThrow();
expect(() => validateType(schema, value, false)).toThrow();
},
);

it("preserves empty and false concrete values without coercion", () => {
for (const [schema, value] of [
[sc.string(), ""],
[sc.number(), 0],
[sc.boolean(), false],
[sc.array(sc.string()), []],
] as [SchemaType, unknown][]) {
expect(() => validateType(schema, value, false)).not.toThrow();
}
expect(() => validateType(sc.number(), "0", false)).toThrow();
});

it("only the exact { $result: string } shape bypasses validation", () => {
// A non-string id, an extra key, or an array are NOT references and must
// validate normally (and thus throw against a string schema).
Expand Down
57 changes: 45 additions & 12 deletions ts/packages/dispatcher/dispatcher/src/execute/actionHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,15 +859,28 @@ export async function executeActions(
const translationResult = await translatePendingRequestAction(
action,
context,
pending.completedActions,
actionIndex,
);

const requestAction = translationResult.requestAction;
if (!(await canExecute(requestAction.actions, context))) {
const error =
"Deferred actions were not executed because they are unknown or disabled. " +
"Completed actions must not be replayed.";
displayError(error, context);
return {
error,
failedAction: executableAction,
fallbackToReasoning: false,
};
}
actionQueue.unshift(
...(await toPendingActions(
context,
requestAction.actions,
requestAction.history?.entities,
pending.completedActions,
)),
);
continue;
Expand Down Expand Up @@ -921,29 +934,44 @@ export async function executeActions(
};
}

if (result.pendingChoice !== undefined) {
if (actionQueue.length > 0 || result.additionalActions?.length) {
const error =
`Action ${getFullActionName(executableAction)} is awaiting a user choice. ` +
"Remaining steps were not executed and will not resume automatically. " +
"Respond to the choice to continue only this action; do not replay earlier completed actions.";
displayError(error, context);
return {
error,
failedAction: executableAction,
fallbackToReasoning: false,
};
}
return;
}

const resultEntityId = executableAction.resultEntityId;
if (resultEntityId !== undefined) {
if (result.resultEntity === undefined) {
throw new Error(
`Action ${getFullActionName(
executableAction,
)} did not return a result entity.`,
);
}
if (resultEntityResolver === undefined) {
throw new Error(
`Internal error: resultEntityResolver is undefined`,
);
}
resultEntityResolver.setResultEntity(
`\${result-${resultEntityId}}`,
{
...result.resultEntity,
sourceAppAgentName: appAgentName,
},
result.resultEntity === undefined
? undefined
: {
...result.resultEntity,
sourceAppAgentName: appAgentName,
},
result.resultValue,
);
}
pending.completedActions.push({
executableAction: structuredClone(executableAction),
result: structuredClone(result),
});

if (result.activityContext !== undefined) {
if (actionQueue.length > 0) {
Expand Down Expand Up @@ -1014,7 +1042,12 @@ export async function executeActions(
);
// REVIEW: assume that the agent will fill the entities already? Also, current format doesn't support resultEntityIds.
actionQueue.unshift(
...(await toPendingActions(context, actions, undefined)),
...(await toPendingActions(
context,
actions,
undefined,
pending.completedActions,
)),
);
} catch (e) {
if (structured !== undefined) throw e;
Expand Down
Loading
Loading