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
23 changes: 23 additions & 0 deletions .changeset/ag2-agenttask-emission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@buildtovalue/engine': minor
'@buildtovalue/agentflow': minor
---

AG-2.2 etapa 4 — fronteira do `agentTask` (ADENDO-02 D27) + proveniência verificada (D30).

**engine:** `agentTask` passa a ser tratado como uma ESPERA determinística (espelho
do `serviceTask`): ao ser alcançado, emite `CreateJob{ jobType: 'agent' }` com
`payload { elementId, agentRef }` (a ref DECLARADA no BPMN — o host substitui pelo
pin efetivo), o token pausa, e `JobCompleted` retoma o avanço. O INTERIOR do agente
(o walk LLM/tool/decision) roda no host, FORA do caminho determinístico — nunca entra
no engine nem no corpus de replay. Só o RESULTADO (variáveis via `JobCompleted`, D13)
volta. Corpus de replay ganha o cenário do avanço ao redor (byte-idêntico) e um lint
(aceite 7) que FALHA se qualquer fixture contiver interior de agente — a invariante
D27 verificada pelos dois lados. `agentTask` sem `agentWorkflowRef` → incidente
estrutural (o lint de deploy deveria barrar antes).

**agentflow:** `FactSource` ganha a terceira rung `'evidencia-verificada'` (D30). O
tipo carrega o rótulo para a trilha do host gravar; `simulate`/`simulateSquad` NUNCA
o emitem — um mock determinístico não verifica realidade, então a evidência verificada
é exclusiva do runtime real. Teste do aceite: mesmo com todos os papéis declarados como
evidência, o simulador nunca produz o rótulo verificado.
11 changes: 9 additions & 2 deletions docs/api/agentflow/src.md
Original file line number Diff line number Diff line change
Expand Up @@ -2652,10 +2652,17 @@ How writes to a context key combine.
### FactSource

```ts
type FactSource = "fixture" | "evidencia-declarada";
type FactSource = "fixture" | "evidencia-declarada" | "evidencia-verificada";
```

Provenance of a fact (E6): a mock fixture, or host-declared real evidence.
Provenance of a fact (E6 / ADENDO-03 D30). Three rungs, in ascending trust:
- `fixture` — a mock output from a declared fixture (the CI/simulate path);
- `evidencia-declarada` — the host DECLARES this fixture as captured real
evidence (still not verified by the runtime);
- `evidencia-verificada` — emitted ONLY by the real runtime `run` (D30): the
fact was produced and verified by an actual model/tool call. `simulate`
NEVER emits it — a determinist mock cannot verify reality. The union
carries the label so the host trail can record it; the simulator does not.

***

Expand Down
13 changes: 11 additions & 2 deletions packages/agentflow/src/squadSim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,17 @@ import type { AgentRef } from './ref.js';
import type { ContextContract, SquadEdge, SquadManifest } from './squad.js';
import type { Fixtures, SimulationState } from './simTypes.js';

/** Provenance of a fact (E6): a mock fixture, or host-declared real evidence. */
export type FactSource = 'fixture' | 'evidencia-declarada';
/**
* Provenance of a fact (E6 / ADENDO-03 D30). Three rungs, in ascending trust:
* - `fixture` — a mock output from a declared fixture (the CI/simulate path);
* - `evidencia-declarada` — the host DECLARES this fixture as captured real
* evidence (still not verified by the runtime);
* - `evidencia-verificada` — emitted ONLY by the real runtime `run` (D30): the
* fact was produced and verified by an actual model/tool call. `simulate`
* NEVER emits it — a determinist mock cannot verify reality. The union
* carries the label so the host trail can record it; the simulator does not.
*/
export type FactSource = 'fixture' | 'evidencia-declarada' | 'evidencia-verificada';

/** The kind of a fact — the filterable "type" (D1 fact chain). */
export type FactKind = 'intencao' | 'acao' | 'io' | 'decisao' | 'evidencia' | 'parada';
Expand Down
13 changes: 13 additions & 0 deletions packages/agentflow/tests/squadSim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@ describe('simulateSquad — provenance + masking (E6/D1)', () => {
expect(pesqFacts.every((f) => f.source === 'fixture')).toBe(true);
});

it('NUNCA emite evidencia-verificada — o simulate é mock, só o run real verifica (D30)', () => {
// A rung de máxima confiança do FactSource existe no tipo (a trilha do host a
// grava), mas um simulador determinístico não pode VERIFICAR realidade. Mesmo
// com TODOS os papéis declarados como evidência, o simulate nunca emite o
// rótulo verificado — ele é exclusivo do runtime real (ADENDO-03 D30).
const res = simulateSquad(manifest(), {
resolveWorkflow,
fixturesByRole,
declaredEvidenceRoles: ['revisor', 'pesquisador'],
});
expect(res.facts.some((f) => f.source === 'evidencia-verificada')).toBe(false);
});

it('masks a sensitive context key conservatively when no policy is injected', () => {
const res = simulateSquad(manifest(), { resolveWorkflow, fixturesByRole, contract });
const evidence = res.facts.find((f) => f.agent === 'pesquisador' && f.kind === 'evidencia');
Expand Down
27 changes: 27 additions & 0 deletions packages/engine/src/advance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,33 @@ function settle(ctx: Ctx, token: Token): void {
armBoundaryTimers(ctx, node, token);
return;
}
case 'agentTask': {
// ADENDO-02 D27: o agentTask é uma ESPERA determinística como o serviceTask —
// emite um job `type:"agent"`, o token pausa, e `JobCompleted` retoma o avanço.
// O INTERIOR do agente (o walk LLM/tool) roda no worker, FORA do caminho
// determinístico do engine — nunca entra aqui, nunca no replay. Só o RESULTADO
// (JobCompleted.result → variáveis, escritas pelo host, D13) volta ao engine.
const agentRef = readString(node, 'agentWorkflowRef');
if (agentRef === undefined) {
structuralIncident(ctx, 'invalidDefinition', `agentTask ${node.id} sem properties.agentWorkflowRef (deploy lint deveria ter rejeitado)`);
return;
}
const waitKey = waitKeyOf(node.id, token.id);
ctx.state.waits.push({ kind: 'job', elementId: node.id, tokenId: token.id, waitKey });
// `agentRef` no payload = a ref DECLARADA no BPMN (pode ser flutuante). O HOST
// substitui pelo PIN EFETIVO resolvido no start (nunca por execução de job) ao
// materializar o job — o engine permanece puro (sem registry). `elementId` no
// payload para o worker correlacionar o pin e a trilha.
ctx.effects.push({
type: 'CreateJob',
waitKey,
elementId: node.id,
jobType: 'agent',
payload: { elementId: node.id, agentRef, ...readObject(node, 'jobPayload') },
});
armBoundaryTimers(ctx, node, token);
return;
}
case 'exclusiveGateway': {
routeExclusive(ctx, node, token);
return;
Expand Down
46 changes: 46 additions & 0 deletions packages/engine/tests/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,52 @@ describe('fluxo sequencial e término', () => {
expect(replayed.ok).toBe(false);
if (!replayed.ok) expect(replayed.rejection.kind).toBe('alreadyClosed');
});

it('agent task emite CreateJob(agent) com agentRef+elementId; JobCompleted avança (fronteira D27)', () => {
// ADENDO-02 D27: o agentTask é ESPERA determinística — emite job `agent`, pausa,
// e o RESULTADO (JobCompleted) retoma o avanço. O interior do agente NÃO entra
// no engine (roda no worker) — aqui só o contorno determinístico é exercido.
const engine = createEngine(
flow(['s:startEvent', 'a:agentTask', 'e:endEvent'], ['s->a', 'a->e'], (d) => {
d.nodes.a.properties.agentWorkflowRef = 'agnt-aprova'; // declarada (flutuante); host pina
}),
);
const { state, effects } = start(engine);
expect(state.status).toBe('active'); // pausou na espera do agente
expect(effectsOf(effects, 'CreateJob')[0]).toMatchObject({
waitKey: 'a:i1',
jobType: 'agent',
// payload carrega elementId + a ref DECLARADA (o host substitui pelo pin efetivo)
payload: { elementId: 'a', agentRef: 'agnt-aprova' },
});
// o RESULTADO do agente (variáveis) volta pelo host via JobCompleted → avança.
const done = engine.advance(state, {
type: 'JobCompleted',
now: NOW,
waitKey: 'a:i1',
variables: vars,
});
expect(done.ok && done.state.status).toBe('completed');

// replay do MESMO JobCompleted → rejeição tipada (a espera já fechou).
const replayed = engine.advance((done as { state: InstanceState }).state ?? state, {
type: 'JobCompleted',
now: NOW,
waitKey: 'a:i1',
variables: vars,
});
expect(replayed.ok).toBe(false);
if (!replayed.ok) expect(replayed.rejection.kind).toBe('alreadyClosed');
});

it('agentTask sem agentWorkflowRef → incidente estrutural (deploy lint deveria barrar)', () => {
const engine = createEngine(
flow(['s:startEvent', 'a:agentTask', 'e:endEvent'], ['s->a', 'a->e']),
);
const { effects } = start(engine);
expect(effectsOf(effects, 'RaiseIncident')[0]).toMatchObject({ kind: 'invalidDefinition' });
expect(effectsOf(effects, 'CreateJob')).toHaveLength(0);
});
});

describe('XOR com condições (avaliador injetado)', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[
{
"event": {
"type": "StartInstance",
"instanceId": "i1",
"variables": {},
"now": "2026-07-22T12:00:00.000Z"
},
"state": "{\"definitionRef\":{\"bpmnVersion\":\"1.0.0\",\"registryRef\":\"reg:test@1\"},\"engineVersion\":\"1.1.0-next.2\",\"joinArrivals\":{},\"sequence\":1,\"stateSchemaVersion\":1,\"status\":\"active\",\"tokens\":[{\"elementId\":\"a\",\"id\":\"i1\",\"scopeId\":\"root\"}],\"waits\":[{\"elementId\":\"a\",\"kind\":\"job\",\"tokenId\":\"i1\",\"waitKey\":\"a:i1\"}]}",
"effects": "[{\"kind\":\"instanceStarted\",\"payload\":{\"instanceId\":\"i1\"},\"type\":\"EmitHistory\"},{\"elementId\":\"a\",\"jobType\":\"agent\",\"payload\":{\"agentRef\":\"agnt-aprova\",\"elementId\":\"a\"},\"type\":\"CreateJob\",\"waitKey\":\"a:i1\"}]"
},
{
"event": {
"type": "JobCompleted",
"waitKey": "a:i1",
"variables": {},
"result": {
"aprovado": true
},
"now": "2026-07-22T12:00:00.000Z"
},
"state": "{\"definitionRef\":{\"bpmnVersion\":\"1.0.0\",\"registryRef\":\"reg:test@1\"},\"engineVersion\":\"1.1.0-next.2\",\"joinArrivals\":{},\"sequence\":3,\"stateSchemaVersion\":1,\"status\":\"completed\",\"tokens\":[],\"waits\":[]}",
"effects": "[{\"type\":\"CompleteInstance\"},{\"kind\":\"instanceCompleted\",\"payload\":{},\"type\":\"EmitHistory\"}]"
}
]
53 changes: 53 additions & 0 deletions packages/engine/tests/replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,21 @@ const SCENARIOS: ReplayScenario[] = [
{ type: 'CancelInstance', variables: vars, reason: 'replay-fixture' },
],
},
{
// AG-2.2 etapa 4 (D27): o avanço AO REDOR do agentTask é determinístico e
// entra no corpus — `start → CreateJob(agent) → JobCompleted → segue`. O
// INTERIOR do agente (walk LLM/tool) NUNCA aparece aqui: o engine só vê a
// espera e o RESULTADO. O lint do aceite 7 (abaixo) prova o outro lado.
name: 'agenttask-avanco-ao-redor',
diagram: () =>
flow(['s:startEvent', 'a:agentTask', 'e:endEvent'], ['s->a', 'a->e'], (d) => {
d.nodes.a.properties.agentWorkflowRef = 'agnt-aprova';
}),
events: [
{ type: 'StartInstance', instanceId: 'i1', variables: vars },
{ type: 'JobCompleted', waitKey: 'a:i1', variables: vars, result: { aprovado: true } },
],
},
];

interface ReplayStep {
Expand Down Expand Up @@ -193,3 +208,41 @@ describe('corpus de replay (D6 — identidade byte a byte)', () => {
});
}
});

/**
* Aceite 7 (AG-2.2 etapa 4, invariante D27 pelos DOIS lados): o corpus de replay
* exercita o avanço AO REDOR do agentTask (cenário acima) e este lint prova o
* OUTRO lado — NENHUMA fixture contém o INTERIOR do agente. O interior (walk
* LLM/tool/decision, I/O do agente, cadeia de fatos) é não-determinístico (D27)
* e jamais pode entrar num corpus que se exige byte-idêntico: seria replay de
* algo irreproduzível. Proibição VERIFICADA, não só declarada.
*/
const AGENT_INTERIOR_MARKERS: readonly string[] = [
'"type":"llm"', // nós internos do agentflow (llm/tool/decision)
'"type":"tool"',
'promptRef', // config de nó llm
'usesTool', // config de nó tool
'agent_io', // coluna de I/O mascarado da trilha (host)
'"agent:intencao"', // kinds da cadeia de fatos do agente
'"agent:acao"',
'"agent:io"',
'"agent:evidencia"',
'MASKED', // token de máscara da trilha
];

describe('aceite 7 (D27) — nenhuma fixture de replay contém interior de agentTask', () => {
const fixtures = SCENARIOS.map((s) => `${s.name}.json`);
for (const fixtureName of fixtures) {
it(`${fixtureName} não vaza interior de agente`, () => {
const file = join(FIXTURES_DIR, fixtureName);
if (!existsSync(file)) return; // gerada no describe acima; nada a varrer ainda
const raw = readFileSync(file, 'utf8');
for (const marker of AGENT_INTERIOR_MARKERS) {
expect(
raw.includes(marker),
`fixture ${fixtureName} contém marcador de INTERIOR de agente '${marker}' — o replay do engine nunca pode conter o não-determinístico (D27)`,
).toBe(false);
}
});
}
});
Loading