@pikku/core 0.12.86 → 0.12.88
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/dist/dev/hot-reload.d.ts +1 -1
- package/dist/dev/hot-reload.js +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.js +1 -1
- package/dist/wirings/agent/agent-memory.d.ts +11 -0
- package/dist/wirings/agent/agent-memory.js +17 -2
- package/dist/wirings/agent/agent-stream.js +115 -91
- package/dist/wirings/agent/agent.types.d.ts +9 -8
- package/dist/wirings/variable/validate-variable-definitions.js +2 -2
- package/dist/wirings/variable/variable.types.d.ts +13 -7
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +2 -2
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +20 -6
- package/dist/wirings/workflow/pikku-workflow-service.js +25 -25
- package/dist/wirings/workflow/workflow-step-claim.d.ts +16 -0
- package/dist/wirings/workflow/workflow-step-claim.js +27 -0
- package/knowledge/decisions/security/a-scaffold-flag-says-a-surface-exists-not-who-may-call-it.md +47 -0
- package/knowledge/decisions/security/index.md +2 -1
- package/knowledge/decisions/security/scaffold-features-are-authenticated-unless-opted-out.md +6 -0
- package/package.json +1 -1
- package/src/dev/hot-reload.ts +1 -1
- package/src/types/index.ts +24 -1
- package/src/wirings/agent/agent-memory.test.ts +73 -0
- package/src/wirings/agent/agent-memory.ts +16 -2
- package/src/wirings/agent/agent-middleware.types.test.ts +41 -0
- package/src/wirings/agent/agent-stream-delegate.test.ts +381 -0
- package/src/wirings/agent/agent-stream.ts +158 -78
- package/src/wirings/agent/agent.types.ts +9 -8
- package/src/wirings/variable/validate-variable-definitions.test.ts +11 -10
- package/src/wirings/variable/validate-variable-definitions.ts +2 -2
- package/src/wirings/variable/variable.types.ts +13 -7
- package/src/wirings/workflow/pikku-scenario-service.ts +29 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +31 -27
- package/src/wirings/workflow/workflow-step-claim.ts +46 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/tsconfig.type-tests.json +2 -1
|
@@ -9,13 +9,14 @@ import { deriveInvocationId } from './workflow-invocation-id.js';
|
|
|
9
9
|
import { approvalDeciderFrom } from './workflow-approval-policy.js';
|
|
10
10
|
import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
|
|
11
11
|
import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, } from './workflow-constants.js';
|
|
12
|
-
import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError,
|
|
12
|
+
import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError, WorkflowStepNameNotString, WorkflowSuspendedException, } from './workflow-errors.js';
|
|
13
13
|
import { resolveWorkflowMeta } from './workflow-meta-resolver.js';
|
|
14
14
|
import { jobGroupFor, orchestratorQueueName, resolveWorkflowConfig, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
|
|
15
15
|
import { wireWorkflowQueueWorkers } from './workflow-queue-wiring.js';
|
|
16
16
|
import { approvalStepNameFor, evaluateApprovalStep, recordApprovalDecision, } from './workflow-approval.js';
|
|
17
17
|
import { auditApprovalDecision } from './workflow-approval-audit.js';
|
|
18
18
|
import { recordSuspension, suspendStepNameFor } from './workflow-suspend.js';
|
|
19
|
+
import { claimStepByReadThenWrite } from './workflow-step-claim.js';
|
|
19
20
|
import { RedispatchBackoff, sweepStalledRuns, sweepUndispatchedSteps, } from './workflow-recovery.js';
|
|
20
21
|
export class PikkuWorkflowService {
|
|
21
22
|
runExtension;
|
|
@@ -261,12 +262,12 @@ export class PikkuWorkflowService {
|
|
|
261
262
|
*
|
|
262
263
|
* Returns nothing by default so a store that cannot express the query keeps
|
|
263
264
|
* working unchanged — and, because it does not opt in, gains no re-dispatches
|
|
264
|
-
* either. A store must have an atomic `
|
|
265
|
-
* or no concurrency for one to exclude: the relay makes
|
|
266
|
-
* routine, and the claim
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
265
|
+
* either. A store must have an atomic `claimStepForExecution` before
|
|
266
|
+
* overriding this, or no concurrency for one to exclude: the relay makes
|
|
267
|
+
* duplicate dispatch routine, and the claim is what keeps a duplicate from
|
|
268
|
+
* becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
|
|
269
|
+
* status-guarded claim, `in-memory` on being inline and single-process;
|
|
270
|
+
* `mongodb` still qualifies on neither.
|
|
270
271
|
*/
|
|
271
272
|
async findUndispatchedSteps(_before, _limit) {
|
|
272
273
|
return [];
|
|
@@ -697,25 +698,24 @@ export class PikkuWorkflowService {
|
|
|
697
698
|
this.exitExecution(runId);
|
|
698
699
|
}
|
|
699
700
|
}
|
|
701
|
+
/**
|
|
702
|
+
* Take sole ownership of a step before it runs, returning the state to run
|
|
703
|
+
* under — or `null` when another dispatch already owns it.
|
|
704
|
+
*
|
|
705
|
+
* Dispatch is at-least-once by design: the relay re-dispatches steps it
|
|
706
|
+
* believes were dropped, and a queue can redeliver a job it already handed
|
|
707
|
+
* out. This is the one place that keeps a duplicate dispatch from becoming a
|
|
708
|
+
* second execution of a side-effecting step, so it is only as strong as the
|
|
709
|
+
* exclusion it is built on — and `withStepLock` excludes nothing unless the
|
|
710
|
+
* store backs it with a real primitive. A store able to express the decision
|
|
711
|
+
* as one conditional write should override this rather than reach for a lock,
|
|
712
|
+
* which is what `@pikku/kysely` does with a status-guarded `UPDATE`.
|
|
713
|
+
*/
|
|
714
|
+
async claimStepForExecution(runId, stepName, rpcName) {
|
|
715
|
+
return this.withStepLock(runId, stepName, () => claimStepByReadThenWrite(this, runId, stepName, rpcName));
|
|
716
|
+
}
|
|
700
717
|
async executeWorkflowStepInner(runId, stepName, rpcName, data, rpcService) {
|
|
701
|
-
const claimed = await this.
|
|
702
|
-
const stepState = await this.getStepState(runId, stepName);
|
|
703
|
-
// knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
|
|
704
|
-
if (stepState.rpcName !== undefined &&
|
|
705
|
-
stepState.rpcName !== (rpcName ?? null)) {
|
|
706
|
-
throw new WorkflowStepFunctionMismatchError(runId, stepName);
|
|
707
|
-
}
|
|
708
|
-
if (stepState.status === 'succeeded' || stepState.status === 'running') {
|
|
709
|
-
return null;
|
|
710
|
-
}
|
|
711
|
-
if (stepState.status === 'failed') {
|
|
712
|
-
return this.createRetryAttempt(stepState.stepId, 'running');
|
|
713
|
-
}
|
|
714
|
-
if (stepState.status === 'pending' || stepState.status === 'scheduled') {
|
|
715
|
-
await this.setStepRunning(stepState.stepId);
|
|
716
|
-
}
|
|
717
|
-
return stepState;
|
|
718
|
-
});
|
|
718
|
+
const claimed = await this.claimStepForExecution(runId, stepName, rpcName);
|
|
719
719
|
if (!claimed) {
|
|
720
720
|
return;
|
|
721
721
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { StepState } from './workflow.types.js';
|
|
2
|
+
/** What claiming a step needs from the workflow service. */
|
|
3
|
+
export type StepClaimStore = {
|
|
4
|
+
getStepState(runId: string, stepName: string): Promise<StepState>;
|
|
5
|
+
setStepRunning(stepId: string): Promise<void>;
|
|
6
|
+
createRetryAttempt(failedStepId: string, status: 'pending' | 'running'): Promise<StepState>;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Decide whether this dispatch owns the step, by reading its state and then
|
|
10
|
+
* writing it — which only excludes a concurrent dispatch when the caller holds
|
|
11
|
+
* a lock that genuinely excludes one.
|
|
12
|
+
*
|
|
13
|
+
* A store that can express the whole decision as a single conditional write
|
|
14
|
+
* should do that instead of calling this.
|
|
15
|
+
*/
|
|
16
|
+
export declare const claimStepByReadThenWrite: (store: StepClaimStore, runId: string, stepName: string, rpcName: string) => Promise<StepState | null>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { WorkflowStepFunctionMismatchError } from './workflow-errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Decide whether this dispatch owns the step, by reading its state and then
|
|
4
|
+
* writing it — which only excludes a concurrent dispatch when the caller holds
|
|
5
|
+
* a lock that genuinely excludes one.
|
|
6
|
+
*
|
|
7
|
+
* A store that can express the whole decision as a single conditional write
|
|
8
|
+
* should do that instead of calling this.
|
|
9
|
+
*/
|
|
10
|
+
export const claimStepByReadThenWrite = async (store, runId, stepName, rpcName) => {
|
|
11
|
+
const stepState = await store.getStepState(runId, stepName);
|
|
12
|
+
// knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
|
|
13
|
+
if (stepState.rpcName !== undefined &&
|
|
14
|
+
stepState.rpcName !== (rpcName ?? null)) {
|
|
15
|
+
throw new WorkflowStepFunctionMismatchError(runId, stepName);
|
|
16
|
+
}
|
|
17
|
+
if (stepState.status === 'succeeded' || stepState.status === 'running') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (stepState.status === 'failed') {
|
|
21
|
+
return store.createRetryAttempt(stepState.stepId, 'running');
|
|
22
|
+
}
|
|
23
|
+
if (stepState.status === 'pending' || stepState.status === 'scheduled') {
|
|
24
|
+
await store.setStepRunning(stepState.stepId);
|
|
25
|
+
}
|
|
26
|
+
return stepState;
|
|
27
|
+
};
|
package/knowledge/decisions/security/a-scaffold-flag-says-a-surface-exists-not-who-may-call-it.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A scaffold flag says a surface exists, not who may call it
|
|
4
|
+
description: scaffold.<feature> is boolean | { path } — the auth field is gone, because the function runner already enforces each generated function's own auth, wiring, scopes and addon
|
|
5
|
+
tags: config, authorization, codegen, scaffold
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A scaffold flag says a surface exists, not who may call it
|
|
9
|
+
|
|
10
|
+
`scaffold.<feature>` carried an `auth` field, defaulting closed — see
|
|
11
|
+
[[scaffold-features-are-authenticated-unless-opted-out]], which this supersedes.
|
|
12
|
+
The field is gone. `PikkuScaffoldFeature` is now `boolean | { path?: string }`:
|
|
13
|
+
whether the surface is generated, and where the file goes.
|
|
14
|
+
|
|
15
|
+
The field was a second gate in front of one that already runs. `resolveSession`
|
|
16
|
+
in `packages/core/src/function/function-runner.ts` throws `ForbiddenError` for a
|
|
17
|
+
`pikkuFunc` with no session, unconditionally; a `pikkuSessionlessFunc` requires
|
|
18
|
+
one when its own `auth: true` says so, when its wiring says so, or when
|
|
19
|
+
`resolveAddonAuth` does. That is where authentication is decided, per function,
|
|
20
|
+
by whoever wrote it. A config flag three directories away could only be coarser
|
|
21
|
+
than that, and being coarser was the whole problem the earlier decision was
|
|
22
|
+
trying to work around by defaulting it closed.
|
|
23
|
+
|
|
24
|
+
So the generated dispatchers — public RPC, public agent, workflow routes and the
|
|
25
|
+
events channel — emit a fixed `auth: false` on every function and wiring they
|
|
26
|
+
write. That is not the scaffold declaring the surface public. It is the
|
|
27
|
+
dispatcher declining to gate, because it does not know what it is forwarding to:
|
|
28
|
+
`rpcCaller` hands whatever name it was given to `rpc.exposed`, and the function
|
|
29
|
+
that resolves there is the one that answers for itself. Omitting the field would
|
|
30
|
+
not have been neutral — a wiring with no `auth` requires a session — so the
|
|
31
|
+
wrapper would reject the call before the gate that decides ever ran, which is a
|
|
32
|
+
harder gate than the config field it replaced, applied by the layer least
|
|
33
|
+
qualified to apply it.
|
|
34
|
+
|
|
35
|
+
The scoped admin surfaces do not emit it. `userAdmin` and `virtualUser` generate
|
|
36
|
+
`pikkuFunc` with `scopes: ['admin:users:...']` — session-required by
|
|
37
|
+
construction, and the function that decides rather than a wrapper in front of
|
|
38
|
+
one. `auth: false` there is refused by `runPikkuFunc` with a warning telling you
|
|
39
|
+
to use `pikkuSessionlessFunc` instead, and `auth: true` would only restate the
|
|
40
|
+
type.
|
|
41
|
+
|
|
42
|
+
`pikku enable <feature>` lost its `--noAuth` flag with the field it wrote.
|
|
43
|
+
|
|
44
|
+
**What this rules out:** reading a scaffold flag as an authorization decision;
|
|
45
|
+
gating a surface in the config instead of on the function that answers it; and
|
|
46
|
+
letting a generated dispatcher inherit the default `auth: true`, which reads as
|
|
47
|
+
"undecided" and behaves as "denied".
|
|
@@ -50,7 +50,8 @@ A rule about who may do what, and which way it fails when it is unsure.
|
|
|
50
50
|
- [Queue job identities are signed at enqueue](queue-job-identities-are-signed-at-enqueue.md) — A job's pikkuUserId is an HMAC claim bound to the queue and payload; an unverifiable claim is dropped, never trusted
|
|
51
51
|
- [Queue jobs carry the producer's pikku user id](queue-jobs-carry-the-producers-pikku-user-id.md) — A job's pikkuUserId is trusted as identity by the worker, so enqueue rights are effectively act-as-user rights
|
|
52
52
|
- [Remote addon tokens are client credentials, not mesh trust](remote-addon-tokens-are-client-credentials-not-mesh-trust.md) — wireRemoteAddon authenticates as a client to a hosted library and fails closed on an empty token; it never uses PIKKU_REMOTE_SECRET
|
|
53
|
-
- [A
|
|
53
|
+
- [A scaffold flag says a surface exists, not who may call it](a-scaffold-flag-says-a-surface-exists-not-who-may-call-it.md) — scaffold.<feature> is boolean | { path }; the auth field is gone because the function runner already enforces each generated function's own auth, wiring, scopes and addon
|
|
54
|
+
- [A scaffolded surface is authenticated unless the config opts out in writing](scaffold-features-are-authenticated-unless-opted-out.md) — SUPERSEDED; retains why the legacy 'auth' | 'no-auth' strings are refused rather than coerced
|
|
54
55
|
- [Scenario-step functions are never externally invocable over RPC](scenario-step-functions-are-never-externally-invocable.md) — rpcExposed requires expose and rejects scenarioStep, so test steps stay reachable only from inside a scenario run
|
|
55
56
|
- [Scope resolution happens at the session boundary and scope sync never deletes](scope-resolution-happens-at-the-session-boundary-and-sync-never-deletes.md) — ScopeService is called when a session is built, never by the function runner, and syncScopes only ever adds — revoking is an explicit operation
|
|
56
57
|
- [Self-authentication is declared, not detected](self-authentication-is-declared-not-detected.md) — A function that authorizes callers in its own body says so with selfAuthenticated; codegen never tries to infer it
|
package/knowledge/decisions/security/scaffold-features-are-authenticated-unless-opted-out.md
CHANGED
|
@@ -7,6 +7,12 @@ tags: config, authorization, codegen, scaffold
|
|
|
7
7
|
|
|
8
8
|
# A scaffolded surface is authenticated unless the config opts out in writing
|
|
9
9
|
|
|
10
|
+
**Superseded by
|
|
11
|
+
[[a-scaffold-flag-says-a-surface-exists-not-who-may-call-it]]**: the `auth`
|
|
12
|
+
field is gone entirely, because the function runner already enforces each
|
|
13
|
+
generated function's own auth. The reasoning below still explains why the
|
|
14
|
+
legacy `'auth' | 'no-auth'` strings are refused rather than coerced.
|
|
15
|
+
|
|
10
16
|
`scaffold.<feature>` was `'auth' | 'no-auth' | false`. Two things were wrong
|
|
11
17
|
with it, and they compounded.
|
|
12
18
|
|
package/package.json
CHANGED
package/src/dev/hot-reload.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { Logger } from '../services/logger.js'
|
|
|
11
11
|
import type { CorePikkuFunctionConfig } from '../function/functions.types.js'
|
|
12
12
|
import { createModuleRunner } from './module-runner.js'
|
|
13
13
|
|
|
14
|
-
export
|
|
14
|
+
export { reloadGeneratedMeta, reconcileAddonRegistry } from './reload-meta.js'
|
|
15
15
|
|
|
16
16
|
interface PikkuDevReloaderOptions {
|
|
17
17
|
srcDirectories: string[]
|
package/src/types/index.ts
CHANGED
|
@@ -1,2 +1,25 @@
|
|
|
1
|
-
export
|
|
1
|
+
export type {
|
|
2
|
+
AuthInstance,
|
|
3
|
+
CommonWireMeta,
|
|
4
|
+
CoreConfig,
|
|
5
|
+
CoreServices,
|
|
6
|
+
CoreSingletonServices,
|
|
7
|
+
CoreUserSession,
|
|
8
|
+
CreateConfig,
|
|
9
|
+
CreateSingletonServices,
|
|
10
|
+
CreateWireServices,
|
|
11
|
+
GetCredential,
|
|
12
|
+
PikkuWire,
|
|
13
|
+
PikkuWiringTypes,
|
|
14
|
+
PostgresConfig,
|
|
15
|
+
SecretlessServices,
|
|
16
|
+
SecurityAuditIssue,
|
|
17
|
+
SecurityAuditReport,
|
|
18
|
+
SecurityAuditSummary,
|
|
19
|
+
SecurityAuditUpdate,
|
|
20
|
+
SecuritySeverity,
|
|
21
|
+
SecurityUpdateLevel,
|
|
22
|
+
ServerLifecycle,
|
|
23
|
+
WireServices,
|
|
24
|
+
} from './core.types.js'
|
|
2
25
|
export type * from './state.types.js'
|
|
@@ -74,6 +74,72 @@ describe('agent-memory', () => {
|
|
|
74
74
|
})
|
|
75
75
|
})
|
|
76
76
|
|
|
77
|
+
test('deepMergeWorkingMemory replaces an array re-emitted in full', () => {
|
|
78
|
+
const result = deepMergeWorkingMemory(
|
|
79
|
+
{ steps: [{ id: 1 }, { id: 2 }], other: 'kept' },
|
|
80
|
+
{ steps: [{ id: 1 }, { id: 2 }, { id: 3 }] }
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
assert.deepEqual(result, {
|
|
84
|
+
steps: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
|
85
|
+
other: 'kept',
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('deepMergeWorkingMemory replaces rather than appends a partial array', () => {
|
|
90
|
+
const result = deepMergeWorkingMemory(
|
|
91
|
+
{ steps: [{ id: 1 }, { id: 2 }] },
|
|
92
|
+
{ steps: [{ id: 3 }] }
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(result, { steps: [{ id: 3 }] })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('deepMergeWorkingMemory clears an array field set to null', () => {
|
|
99
|
+
const result = deepMergeWorkingMemory(
|
|
100
|
+
{ steps: [1, 2, 3], city: 'Berlin' },
|
|
101
|
+
{
|
|
102
|
+
steps: null,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
assert.deepEqual(result, { city: 'Berlin' })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('deepMergeWorkingMemory replaces arrays nested inside merged objects', () => {
|
|
110
|
+
const result = deepMergeWorkingMemory(
|
|
111
|
+
{
|
|
112
|
+
profile: {
|
|
113
|
+
name: 'Yasser',
|
|
114
|
+
tags: ['a', 'b'],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
profile: {
|
|
119
|
+
tags: ['c'],
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
assert.deepEqual(result, {
|
|
125
|
+
profile: {
|
|
126
|
+
name: 'Yasser',
|
|
127
|
+
tags: ['c'],
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('deepMergeWorkingMemory replaces across array and object shape changes', () => {
|
|
133
|
+
assert.deepEqual(
|
|
134
|
+
deepMergeWorkingMemory({ value: { a: 1 } }, { value: [1, 2] }),
|
|
135
|
+
{ value: [1, 2] }
|
|
136
|
+
)
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
deepMergeWorkingMemory({ value: [1, 2] }, { value: { a: 1 } }),
|
|
139
|
+
{ value: { a: 1 } }
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
77
143
|
test('deepMergeWorkingMemory ignores prototype-polluting keys', () => {
|
|
78
144
|
const before = ({} as any).isAdmin
|
|
79
145
|
deepMergeWorkingMemory(
|
|
@@ -107,6 +173,13 @@ describe('agent-memory', () => {
|
|
|
107
173
|
assert.match(prompt, /<working_memory>/)
|
|
108
174
|
})
|
|
109
175
|
|
|
176
|
+
test('buildWorkingMemoryPrompt tells the model arrays are replaced whole', () => {
|
|
177
|
+
const prompt = buildWorkingMemoryPrompt({ steps: [1] })
|
|
178
|
+
|
|
179
|
+
assert.match(prompt, /Arrays are replaced, not merged/)
|
|
180
|
+
assert.match(prompt, /repeat every item you want to keep/)
|
|
181
|
+
})
|
|
182
|
+
|
|
110
183
|
test('buildWorkingMemoryPrompt marks empty memory explicitly', () => {
|
|
111
184
|
const prompt = buildWorkingMemoryPrompt(null)
|
|
112
185
|
assert.match(prompt, /Current working memory: \(empty\)/)
|
|
@@ -39,6 +39,17 @@ export function isWorkingMemoryEnabled(
|
|
|
39
39
|
return !!memoryConfig?.workingMemory && !!storage
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Merges a model-emitted working memory update into the stored state.
|
|
44
|
+
*
|
|
45
|
+
* Objects merge key by key, and `null` deletes a key. Arrays are replaced
|
|
46
|
+
* wholesale — deliberately, not as a side effect of the object guard below.
|
|
47
|
+
* The full state is echoed back to the model every turn by
|
|
48
|
+
* {@link buildWorkingMemoryPrompt}, so it can always re-emit an array in full,
|
|
49
|
+
* and appending instead would duplicate every item each time it did.
|
|
50
|
+
* `buildWorkingMemoryPrompt` states this contract to the model; the two must
|
|
51
|
+
* stay in agreement.
|
|
52
|
+
*/
|
|
42
53
|
export function deepMergeWorkingMemory(
|
|
43
54
|
existing: Record<string, unknown>,
|
|
44
55
|
updates: Record<string, unknown>
|
|
@@ -51,9 +62,10 @@ export function deepMergeWorkingMemory(
|
|
|
51
62
|
const value = updates[key]
|
|
52
63
|
if (value === null) {
|
|
53
64
|
delete result[key]
|
|
65
|
+
} else if (Array.isArray(value)) {
|
|
66
|
+
result[key] = value
|
|
54
67
|
} else if (
|
|
55
68
|
typeof value === 'object' &&
|
|
56
|
-
!Array.isArray(value) &&
|
|
57
69
|
typeof result[key] === 'object' &&
|
|
58
70
|
result[key] !== null &&
|
|
59
71
|
!Array.isArray(result[key])
|
|
@@ -100,7 +112,9 @@ export function buildWorkingMemoryPrompt(
|
|
|
100
112
|
'When you learn new information, output a partial JSON update in <working_memory> tags. ' +
|
|
101
113
|
'Only include durable facts you have actually derived or the user has confirmed. ' +
|
|
102
114
|
'Do not output templates, placeholders, or narration. ' +
|
|
103
|
-
'Only include changed fields. Leave unknown fields untouched. Set a field to null to delete it.'
|
|
115
|
+
'Only include changed fields. Leave unknown fields untouched. Set a field to null to delete it. ' +
|
|
116
|
+
'Arrays are replaced, not merged: to change one, repeat every item you want to keep alongside the new ones, ' +
|
|
117
|
+
'because any item you leave out is deleted.'
|
|
104
118
|
)
|
|
105
119
|
|
|
106
120
|
return parts.join('\n\n')
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Type-checked via tsconfig.type-tests.json; no runtime assertions.
|
|
2
|
+
|
|
3
|
+
import { pikkuAgentMiddleware } from '../../middleware/middleware-factories.js'
|
|
4
|
+
import type { PikkuAgentMiddlewareHooks } from './agent.types.js'
|
|
5
|
+
|
|
6
|
+
const _singletonServicesCompile = pikkuAgentMiddleware<{ count: number }>({
|
|
7
|
+
modifyInput: async ({ logger }, { messages, instructions }) => {
|
|
8
|
+
logger.info(`agent input: ${messages.length} messages`)
|
|
9
|
+
return { messages, instructions }
|
|
10
|
+
},
|
|
11
|
+
modifyOutputStream: async ({ logger }, { event, state }) => {
|
|
12
|
+
state.count = (state.count ?? 0) + 1
|
|
13
|
+
logger.info(`event: ${event.type}`)
|
|
14
|
+
return event
|
|
15
|
+
},
|
|
16
|
+
afterStep: async ({ variables }, { stepNumber }) => {
|
|
17
|
+
variables.get(`STEP_${stepNumber}`)
|
|
18
|
+
},
|
|
19
|
+
onError: async ({ logger }, { error }) => {
|
|
20
|
+
logger.error(error.message)
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
void _singletonServicesCompile
|
|
24
|
+
|
|
25
|
+
const _wireServiceIsNotAvailable: PikkuAgentMiddlewareHooks = {
|
|
26
|
+
// @ts-expect-error — `http` is a wire service, and an agent run has no wire
|
|
27
|
+
modifyInput: async ({ http }, { messages, instructions }) => {
|
|
28
|
+
void http
|
|
29
|
+
return { messages, instructions }
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
void _wireServiceIsNotAvailable
|
|
33
|
+
|
|
34
|
+
const _wireServiceIsNotAvailableToTheFactory = pikkuAgentMiddleware({
|
|
35
|
+
// @ts-expect-error — `channel` is a wire service, and an agent run has no wire
|
|
36
|
+
afterToolCall: async ({ channel }, { result }) => {
|
|
37
|
+
void channel
|
|
38
|
+
return { result }
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
void _wireServiceIsNotAvailableToTheFactory
|