@zooid/core 0.12.0 → 0.14.0

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/src/config.ts CHANGED
@@ -4,6 +4,7 @@ import { parse } from 'yaml'
4
4
  import type { AcpAgentSpec } from './acp-types.js'
5
5
  import { isPreset } from '@zooid/acp-client'
6
6
  import { interpolateEnv, interpolateString } from './env-interpolation.js'
7
+ import { compileMatch } from './match-expression.js'
7
8
  import type {
8
9
  AgentConfig,
9
10
  CliFlags,
@@ -15,10 +16,15 @@ import type {
15
16
  MountConfig,
16
17
  RoomBinding,
17
18
  TransportConfig,
19
+ TriggerConfig,
20
+ TriggerMessage,
21
+ WebhookTriggerConfig,
18
22
  ZooidConfig,
19
23
  ZooidContainerConfig,
20
24
  } from './types.js'
21
25
 
26
+ const WEBHOOK_PROVIDERS = ['github', 'stripe', 'slack', 'standard', 'custom'] as const
27
+
22
28
  const SLUG_RE = /^[a-z0-9-]+$/
23
29
 
24
30
  export interface LoadZooidConfigOptions {
@@ -29,6 +35,13 @@ export interface LoadZooidConfigOptions {
29
35
  * path.
30
36
  */
31
37
  configDir?: string
38
+ /**
39
+ * Cron-expression validator, called as `validateCron(name, expr)` and
40
+ * expected to throw on an invalid expression. `core` takes no cron
41
+ * dependency — `cli` passes croner's parser at load time. Defaults to a
42
+ * field-count check sufficient to catch malformed input at parse time.
43
+ */
44
+ validateCron?: (name: string, expr: string) => void
32
45
  }
33
46
 
34
47
  const AGENT_NAME_RE = /^[a-z][a-z0-9-]{0,31}$/
@@ -790,6 +803,297 @@ function parseAgents(
790
803
  return result
791
804
  }
792
805
 
806
+ function defaultValidateCron(name: string, expr: string): void {
807
+ const parts = expr.trim().split(/\s+/)
808
+ if (parts.length < 5 || parts.length > 7) {
809
+ throw new Error(
810
+ `triggers.${name}.schedule: invalid cron expression ${JSON.stringify(expr)}`,
811
+ )
812
+ }
813
+ }
814
+
815
+ /**
816
+ * Expand a trigger's `as:` to a full MXID. Mirrors the explicit-`user_id`
817
+ * path in `parseTransportBinding`: a value already containing `:` is used
818
+ * as given (and validated); a bare localpart (`cron` or `@cron`) gets
819
+ * `:<server>` appended from the workforce's sole matrix transport. Unlike
820
+ * the *default* user_id (omitted case), this never auto-prefixes a
821
+ * workstation — an explicit value is used as written, so a
822
+ * workstation-scoped bot is written explicitly (`as: myworkstation.cron`).
823
+ */
824
+ function expandTriggerAs(
825
+ name: string,
826
+ as: string,
827
+ transports: Record<string, TransportConfig>,
828
+ ): string {
829
+ if (as.includes(':')) {
830
+ if (!MATRIX_USER_ID_RE.test(as)) {
831
+ throw new Error(`triggers.${name}.as: must be a full MXID (got ${JSON.stringify(as)})`)
832
+ }
833
+ return as
834
+ }
835
+ const bare = as.startsWith('@') ? as : `@${as}`
836
+ if (!MATRIX_USER_LOCALPART_RE.test(bare)) {
837
+ throw new Error(`triggers.${name}.as: must be a full MXID (got ${JSON.stringify(as)})`)
838
+ }
839
+ const matrixTransports = Object.values(transports).filter(
840
+ (t): t is MatrixTransportConfig => t.type === 'matrix',
841
+ )
842
+ if (matrixTransports.length !== 1) {
843
+ throw new Error(
844
+ `triggers.${name}.as: "${as}" is a bare localpart, which requires exactly one matrix ` +
845
+ `transport to expand against (found ${matrixTransports.length}). Use a full MXID instead.`,
846
+ )
847
+ }
848
+ const serverName = deriveServerName(matrixTransports[0]!.user_namespace)
849
+ return `${bare}:${serverName}`
850
+ }
851
+
852
+ function parseTriggers(
853
+ raw: unknown,
854
+ agents: Record<string, AgentConfig>,
855
+ transports: Record<string, TransportConfig>,
856
+ validateCron: (name: string, expr: string) => void,
857
+ processEnv: NodeJS.ProcessEnv,
858
+ configDir: string | undefined,
859
+ ): Record<string, TriggerConfig> {
860
+ if (raw === undefined || raw === null) return {}
861
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
862
+ throw new Error('triggers: must be a mapping')
863
+ }
864
+ const result: Record<string, TriggerConfig> = {}
865
+ for (const [name, val] of Object.entries(raw as Record<string, unknown>)) {
866
+ if (!val || typeof val !== 'object' || Array.isArray(val)) {
867
+ throw new Error(`triggers.${name} must be a mapping`)
868
+ }
869
+ const t = val as Record<string, unknown>
870
+
871
+ if (t.run !== undefined) {
872
+ throw new Error(
873
+ `triggers.${name}.run: is not supported — a trigger posts a message and the ` +
874
+ `agent runs what needs running. See [ZOD081] §Concept.`,
875
+ )
876
+ }
877
+
878
+ if (t.schedule === undefined && t.webhook === undefined) {
879
+ throw new Error(`triggers.${name}: must specify schedule: or webhook:`)
880
+ }
881
+ if (t.schedule !== undefined && t.webhook !== undefined) {
882
+ throw new Error(`triggers.${name}: specify either schedule: or webhook:, not both`)
883
+ }
884
+
885
+ let schedule: string | undefined
886
+ if (t.schedule !== undefined) {
887
+ if (typeof t.schedule !== 'string' || t.schedule.length === 0) {
888
+ throw new Error(`triggers.${name}.schedule: must be a non-empty string`)
889
+ }
890
+ validateCron(name, t.schedule)
891
+ schedule = t.schedule
892
+ }
893
+
894
+ let webhook: WebhookTriggerConfig | undefined
895
+ if (t.webhook !== undefined) {
896
+ webhook = parseWebhookTrigger(name, t.webhook, processEnv, configDir)
897
+ }
898
+
899
+ if (typeof t.as !== 'string' || t.as.length === 0) {
900
+ throw new Error(`triggers.${name}.as: must be a non-empty string`)
901
+ }
902
+ const as = expandTriggerAs(name, t.as, transports)
903
+
904
+ const hasFlat = t.room !== undefined || t.mention !== undefined || t.text !== undefined ||
905
+ t.match !== undefined
906
+ const hasMessages = t.messages !== undefined
907
+ if (hasFlat && hasMessages) {
908
+ throw new Error(
909
+ `triggers.${name}: specify either room:/mention:/text: or messages:, not both`,
910
+ )
911
+ }
912
+ if (!hasFlat && !hasMessages) {
913
+ throw new Error(`triggers.${name}: must specify room:/mention:/text: or messages:`)
914
+ }
915
+
916
+ let rawMessages: unknown[]
917
+ if (hasFlat) {
918
+ // Only a flat trigger's own top-level `match:` gets this unindexed
919
+ // name — once desugared, per-entry validation always speaks in terms
920
+ // of `messages[i]`, whether the entry came from `flat` or `messages:`.
921
+ if (t.match !== undefined && !webhook) {
922
+ throw new Error(`triggers.${name}.match: only applies to a webhook: trigger`)
923
+ }
924
+ rawMessages = [{ room: t.room, mention: t.mention, text: t.text, match: t.match }]
925
+ } else {
926
+ if (!Array.isArray(t.messages)) {
927
+ throw new Error(`triggers.${name}.messages: must be a list`)
928
+ }
929
+ if (t.messages.length === 0) {
930
+ throw new Error(`triggers.${name}.messages: must not be empty`)
931
+ }
932
+ rawMessages = t.messages
933
+ }
934
+
935
+ const messages = rawMessages.map((m, i) =>
936
+ parseTriggerMessage(name, i, m, agents, as, !!webhook),
937
+ )
938
+
939
+ const entry: TriggerConfig = { as, messages }
940
+ if (schedule !== undefined) entry.schedule = schedule
941
+ if (webhook !== undefined) entry.webhook = webhook
942
+ result[name] = entry
943
+ }
944
+ return result
945
+ }
946
+
947
+ /**
948
+ * Parse and validate one entry of `messages:` (or the single entry a flat
949
+ * trigger desugars into). Indexed error names (`triggers.<name>.messages[i]`)
950
+ * apply uniformly here regardless of which spelling produced the entry —
951
+ * only the flat-level `match:` presence check (in `parseTriggers`) still
952
+ * speaks in the unindexed, trigger-level name.
953
+ */
954
+ function parseTriggerMessage(
955
+ name: string,
956
+ index: number,
957
+ raw: unknown,
958
+ agents: Record<string, AgentConfig>,
959
+ as: string,
960
+ hasWebhook: boolean,
961
+ ): TriggerMessage {
962
+ const label = `triggers.${name}.messages[${index}]`
963
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
964
+ throw new Error(`${label}: must be a mapping`)
965
+ }
966
+ const m = raw as Record<string, unknown>
967
+
968
+ if (typeof m.room !== 'string' || m.room.length === 0) {
969
+ throw new Error(`${label}.room: must be a non-empty string`)
970
+ }
971
+ if (!MATRIX_ROOM_IDENT_RE.test(m.room)) {
972
+ throw new Error(`${label}.room: must start with '#' or '!' (got ${JSON.stringify(m.room)})`)
973
+ }
974
+
975
+ if (typeof m.mention !== 'string' || m.mention.length === 0) {
976
+ throw new Error(`${label}.mention: must be a non-empty string`)
977
+ }
978
+ const mentionedAgent = agents[m.mention]
979
+ if (!mentionedAgent) {
980
+ throw new Error(`${label}.mention: unknown agent "${m.mention}"`)
981
+ }
982
+ if (!mentionedAgent.matrix) {
983
+ throw new Error(
984
+ `${label}.mention: agent "${m.mention}" has no matrix: binding — a trigger posts ` +
985
+ `through Matrix, so the mentioned agent must be matrix-bound.`,
986
+ )
987
+ }
988
+ // router.ts never routes an event back to its own sender
989
+ // (`event.sender === a.userId` short-circuits the match), so a trigger
990
+ // that posts as the very agent it mentions would silently never wake it.
991
+ if (as === mentionedAgent.matrix.user_id) {
992
+ throw new Error(
993
+ `triggers.${name}.as: must not equal the mentioned agent's own MXID (${as}) — a message ` +
994
+ `an agent sends never routes back to itself, so this trigger would silently never wake ` +
995
+ `"${m.mention}". Post as a different identity (a dedicated bot, or another agent's).`,
996
+ )
997
+ }
998
+
999
+ if (typeof m.text !== 'string' || m.text.length === 0) {
1000
+ throw new Error(`${label}.text: must be a non-empty string`)
1001
+ }
1002
+
1003
+ const message: TriggerMessage = { room: m.room, mention: m.mention, text: m.text }
1004
+
1005
+ if (m.match !== undefined) {
1006
+ if (typeof m.match !== 'string' || m.match.length === 0) {
1007
+ throw new Error(`${label}.match: must be a non-empty string`)
1008
+ }
1009
+ if (!hasWebhook) {
1010
+ throw new Error(`${label}.match: only applies to a webhook: trigger`)
1011
+ }
1012
+ try {
1013
+ message.match = compileMatch(m.match)
1014
+ } catch (err) {
1015
+ throw new Error(`${label}.match: ${(err as Error).message}`)
1016
+ }
1017
+ }
1018
+
1019
+ return message
1020
+ }
1021
+
1022
+ /**
1023
+ * Parse and validate a `webhook:` block. The secret is interpolated through
1024
+ * `interpolateString` (not `interpolateEnv`'s deny-listed map form) — it is a
1025
+ * single scalar value, and `ZOOID_*` references are legitimate here just as
1026
+ * they are for a transport's `as_token` — [[ZOD082]] §Design 4, Secrets.
1027
+ */
1028
+ function parseWebhookTrigger(
1029
+ name: string,
1030
+ raw: unknown,
1031
+ processEnv: NodeJS.ProcessEnv,
1032
+ configDir: string | undefined,
1033
+ ): WebhookTriggerConfig {
1034
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
1035
+ throw new Error(`triggers.${name}.webhook: must be a mapping`)
1036
+ }
1037
+ const w = raw as Record<string, unknown>
1038
+
1039
+ if (typeof w.provider !== 'string' || w.provider.length === 0) {
1040
+ throw new Error(`triggers.${name}.webhook.provider: must be a non-empty string`)
1041
+ }
1042
+ if (!(WEBHOOK_PROVIDERS as readonly string[]).includes(w.provider)) {
1043
+ throw new Error(
1044
+ `triggers.${name}.webhook.provider: unknown provider ${JSON.stringify(w.provider)} ` +
1045
+ `(expected one of: ${WEBHOOK_PROVIDERS.join(', ')})`,
1046
+ )
1047
+ }
1048
+ const provider = w.provider as WebhookTriggerConfig['provider']
1049
+
1050
+ if (w.event !== undefined) {
1051
+ throw new Error(`triggers.${name}.webhook.event: no longer supported — use match:`)
1052
+ }
1053
+
1054
+ if (typeof w.secret !== 'string' || w.secret.length === 0) {
1055
+ throw new Error(`triggers.${name}.webhook.secret: is required`)
1056
+ }
1057
+ const secret = interpolateString(w.secret, processEnv)
1058
+
1059
+ const config: WebhookTriggerConfig = { provider, secret }
1060
+
1061
+ if (provider !== 'custom') {
1062
+ // Silently ignoring this would leave an operator believing they had
1063
+ // configured a verifier that never ran.
1064
+ if (w.verify !== undefined) {
1065
+ throw new Error(
1066
+ `triggers.${name}.webhook.verify: only applies to provider: custom ` +
1067
+ `(this trigger uses provider: ${provider}, whose signing scheme is built in)`,
1068
+ )
1069
+ }
1070
+ return config
1071
+ }
1072
+
1073
+ // provider: custom — verification is a function the operator supplies,
1074
+ // since no set of declarative fields covers every scheme (ed25519, SHA-1
1075
+ // over sorted params, bespoke timestamped base strings). Resolved here;
1076
+ // the daemon imports it at startup so a bad path fails fast.
1077
+ if (typeof w.verify !== 'string' || w.verify.length === 0) {
1078
+ throw new Error(
1079
+ `triggers.${name}.webhook.verify: is required when provider: custom ` +
1080
+ `(path to a module exporting the verifier function)`,
1081
+ )
1082
+ }
1083
+ if (isAbsolute(w.verify)) {
1084
+ config.verify = w.verify
1085
+ } else if (!configDir) {
1086
+ throw new Error(
1087
+ `triggers.${name}.webhook.verify: relative path ${JSON.stringify(w.verify)} requires ` +
1088
+ `configDir (zooid.yaml directory) — pass it via loadZooidConfig(yaml, { configDir })`,
1089
+ )
1090
+ } else {
1091
+ config.verify = pathResolve(configDir, w.verify)
1092
+ }
1093
+
1094
+ return config
1095
+ }
1096
+
793
1097
  function parseRuntime(raw: unknown): 'local' | 'docker' | 'podman' {
794
1098
  const runtime = raw ?? 'docker'
795
1099
  if (runtime !== 'local' && runtime !== 'docker' && runtime !== 'podman') {
@@ -860,12 +1164,21 @@ export function loadZooidConfig(
860
1164
  const transports = parseTransports(r.transports, processEnv, workstation)
861
1165
  const hooks = zooidHooks(r)
862
1166
  const agents = parseAgents(r.agents, runtime, transports, hooks, processEnv, opts.configDir)
1167
+ const triggers = parseTriggers(
1168
+ r.triggers,
1169
+ agents,
1170
+ transports,
1171
+ opts.validateCron ?? defaultValidateCron,
1172
+ processEnv,
1173
+ opts.configDir,
1174
+ )
863
1175
 
864
1176
  const cfg: ZooidConfig = {
865
1177
  runtime,
866
1178
  transports,
867
1179
  agents,
868
1180
  hooks,
1181
+ triggers,
869
1182
  }
870
1183
  if (workstation !== undefined) cfg.workstation = workstation
871
1184
  if (r.container !== undefined && r.container !== null) {
@@ -955,6 +1268,7 @@ export function mergeCliFlags(base: ZooidConfig, flags: CliFlags): ZooidConfig {
955
1268
  transports: base.transports,
956
1269
  agents: base.agents,
957
1270
  hooks: { ...base.hooks },
1271
+ triggers: base.triggers,
958
1272
  }
959
1273
  if (runtime === 'docker' || runtime === 'podman') {
960
1274
  const image = flags.image ?? base.container?.image
package/src/index.ts CHANGED
@@ -7,10 +7,10 @@ export {
7
7
  findConfigFile,
8
8
  } from './config.js'
9
9
  export type { LoadZooidConfigOptions } from './config.js'
10
- export {
11
- AcpAgentRegistry,
12
- resolveAcpAgentSpec,
13
- } from './acp-registry.js'
10
+ export { renderTemplate } from './render-template.js'
11
+ export { compileMatch, evaluateMatch } from './match-expression.js'
12
+ export type { MatchContext } from './match-expression.js'
13
+ export { AcpAgentRegistry, resolveAcpAgentSpec } from './acp-registry.js'
14
14
  export {
15
15
  ApprovalCorrelator,
16
16
  type RegisteredApproval,
@@ -24,12 +24,7 @@ export type {
24
24
  ContextSpawnFactory,
25
25
  } from './acp-registry.js'
26
26
  export type { TapEvent } from '@zooid/acp-client'
27
- export type {
28
- AcpAgentSpec,
29
- AcpMount,
30
- AcpRuntime,
31
- AcpSpawnSpec,
32
- } from './acp-types.js'
27
+ export type { AcpAgentSpec, AcpMount, AcpRuntime, AcpSpawnSpec } from './acp-types.js'
33
28
  export type {
34
29
  AgentConfig,
35
30
  ContainerConfig,
@@ -46,14 +41,20 @@ export type {
46
41
  Transport,
47
42
  InboundMessage,
48
43
  ThreadRef,
44
+ TriggerConfig,
45
+ TriggerMessage,
46
+ WebhookTriggerConfig,
49
47
  } from './types.js'
50
48
  export type {
51
49
  HistoryOptions,
52
50
  HistoryPage,
53
51
  Message,
54
52
  Member,
55
- ChannelInfo,
53
+ RoomInfo,
54
+ SendMessageInput,
55
+ SendMessageResult,
56
56
  ThreadOverview,
57
57
  ThreadOverviewPage,
58
58
  TransportContextProvider,
59
59
  } from './transport-context.js'
60
+ export * from './task-actions.js'
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { compileMatch, evaluateMatch } from './match-expression.js'
3
+
4
+ const body = {
5
+ action: 'closed',
6
+ number: 42,
7
+ pull_request: { merged: true },
8
+ repository: { full_name: 'zooid-ai/zooid' },
9
+ }
10
+ const ctx = { event: 'pull_request', body, headers: { 'x-github-event': 'pull_request' }, output: '{}' }
11
+
12
+ describe('evaluateMatch', () => {
13
+ it('is true when the predicate holds', () => {
14
+ expect(
15
+ evaluateMatch(compileMatch('event == "pull_request" && body.action == "closed" && body.pull_request.merged'), ctx),
16
+ ).toBe(true)
17
+ })
18
+
19
+ it('is false when the predicate does not hold', () => {
20
+ expect(evaluateMatch(compileMatch('body.action == "opened"'), ctx)).toBe(false)
21
+ })
22
+
23
+ it('reads headers', () => {
24
+ expect(evaluateMatch(compileMatch('headers["x-github-event"] == "pull_request"'), ctx)).toBe(true)
25
+ })
26
+
27
+ // The library returns a CelError value for a missing field rather than
28
+ // throwing or returning false. A merged-PR filter therefore errors on every
29
+ // issues delivery, which is ordinary and must read as "no match".
30
+ it('treats a missing field as no match, not an error and not a fire', () => {
31
+ const m = compileMatch('body.pull_request.merged')
32
+ expect(evaluateMatch(m, { ...ctx, body: { action: 'opened' } })).toBe(false)
33
+ })
34
+
35
+ it('supports has() so an operator can guard explicitly', () => {
36
+ const m = compileMatch('has(body.pull_request) && body.pull_request.merged')
37
+ expect(evaluateMatch(m, { ...ctx, body: { action: 'opened' } })).toBe(false)
38
+ expect(evaluateMatch(m, ctx)).toBe(true)
39
+ })
40
+
41
+ // Fail closed: only an actual boolean true fires. A non-boolean result must
42
+ // never be coerced.
43
+ it('does not fire on a truthy non-boolean result', () => {
44
+ expect(evaluateMatch(compileMatch('body.action'), ctx)).toBe(false)
45
+ expect(evaluateMatch(compileMatch('body.number'), ctx)).toBe(false)
46
+ })
47
+
48
+ it('does not fire on an undeclared variable', () => {
49
+ expect(evaluateMatch(compileMatch('nosuchthing == 1'), ctx)).toBe(false)
50
+ })
51
+ })
52
+
53
+ describe('compileMatch', () => {
54
+ it('rejects a syntax error at compile time, so a typo fails the daemon at boot', () => {
55
+ expect(() => compileMatch('body.action ==')).toThrow()
56
+ })
57
+
58
+ it('accepts a valid expression', () => {
59
+ expect(() => compileMatch('body.action == "opened"')).not.toThrow()
60
+ })
61
+ })
@@ -0,0 +1,33 @@
1
+ import { parse, run, type CelInput } from '@bufbuild/cel'
2
+
3
+ export interface MatchContext {
4
+ event: string | undefined
5
+ body: unknown
6
+ headers: Record<string, string>
7
+ output: string
8
+ }
9
+
10
+ /** Parse-check an expression. Throws on a syntax error, so a typo fails at config load. */
11
+ export function compileMatch(expr: string): string {
12
+ parse(expr)
13
+ return expr
14
+ }
15
+
16
+ /**
17
+ * Only an actual `true` fires. Everything else is "no match":
18
+ * - a CelError value (missing field, undeclared variable) — the library
19
+ * returns these rather than throwing, and a merged-PR filter legitimately
20
+ * errors on every issues delivery;
21
+ * - a truthy non-boolean (a string, a number), which must never be coerced;
22
+ * - a throw.
23
+ * Failing closed matters more here than anywhere else in the ingress: this is
24
+ * the only place an operator's typo could otherwise open a filter.
25
+ */
26
+ export function evaluateMatch(expr: string, ctx: MatchContext): boolean {
27
+ try {
28
+ const result = run(expr, { ...ctx } as Record<string, CelInput>)
29
+ return result === true
30
+ } catch {
31
+ return false
32
+ }
33
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderTemplate } from './render-template.js'
3
+
4
+ const ctx = {
5
+ event: 'issues',
6
+ body: { issue: { number: 23 }, repository: { full_name: 'zooid-ai/zooid' } },
7
+ headers: {},
8
+ output: '{\n "issue": {}\n}',
9
+ }
10
+
11
+ describe('renderTemplate', () => {
12
+ it('interpolates an expression', () => {
13
+ expect(renderTemplate('Triage ${body.repository.full_name}#${body.issue.number}.', ctx)).toBe(
14
+ 'Triage zooid-ai/zooid#23.',
15
+ )
16
+ })
17
+
18
+ it('leaves text with no placeholders alone', () => {
19
+ expect(renderTemplate('A PR merged.', ctx)).toBe('A PR merged.')
20
+ })
21
+
22
+ // ${output} is not a special case: it is a bound variable like any other, so
23
+ // a whole-payload dump still works for senders we control ([[ZOD081]] §2).
24
+ it('still supports ${output}', () => {
25
+ expect(renderTemplate('Payload:\n${output}', ctx)).toContain('"issue"')
26
+ })
27
+
28
+ // A bad placeholder must not take the message down, and must not silently
29
+ // paste an error object into a room.
30
+ it('renders an unresolvable placeholder as empty rather than throwing', () => {
31
+ expect(renderTemplate('x${body.nope.deep}y', ctx)).toBe('xy')
32
+ })
33
+
34
+ it('does not re-scan substituted content, so a payload cannot inject a placeholder', () => {
35
+ const evil = { ...ctx, body: { title: '${body.secret}' }, }
36
+ expect(renderTemplate('${body.title}', evil)).toBe('${body.secret}')
37
+ })
38
+ })
@@ -0,0 +1,32 @@
1
+ import { run, isCelError, type CelInput } from '@bufbuild/cel'
2
+ import type { MatchContext } from './match-expression.js'
3
+
4
+ // Non-greedy so `${a}...${b}` is two placeholders, not one spanning both.
5
+ const PLACEHOLDER_RE = /\$\{([\s\S]+?)\}/g
6
+
7
+ /**
8
+ * Fill `${...}` placeholders in a trigger's `text:`. Each placeholder is a
9
+ * CEL expression over the same bindings `match:` sees — one expression
10
+ * language, not two. `${output}` is not a special case: `output` is just
11
+ * another bound variable, so a whole-payload dump still works for senders we
12
+ * control ([[ZOD081]] §2).
13
+ *
14
+ * An unresolvable placeholder (missing field, bad expression) renders as
15
+ * empty rather than throwing or pasting an error object into a room — a bad
16
+ * placeholder must not take the message down.
17
+ *
18
+ * The result is never re-scanned, so a payload that itself contains the
19
+ * literal string `${...}` cannot inject a placeholder.
20
+ */
21
+ export function renderTemplate(template: string, ctx: MatchContext): string {
22
+ return template.replace(PLACEHOLDER_RE, (_match, expr: string) => {
23
+ let result: unknown
24
+ try {
25
+ result = run(expr, { ...ctx } as Record<string, CelInput>)
26
+ } catch {
27
+ return ''
28
+ }
29
+ if (result === undefined || isCelError(result)) return ''
30
+ return String(result)
31
+ })
32
+ }
@@ -0,0 +1,84 @@
1
+ /** Harness-independent delegated-task contracts ([[ZOD072]]). */
2
+ export interface TaskCallerRef {
3
+ agentName: string
4
+ channelId: string
5
+ threadRoot: string
6
+ sessionKey: string
7
+ }
8
+ export interface StartTaskSpec {
9
+ agent: string
10
+ prompt: string
11
+ }
12
+ export interface StartTasksInput {
13
+ tasks: StartTaskSpec[]
14
+ notify?: 'caller' | 'none'
15
+ }
16
+ export type StartTaskResult =
17
+ | { agent: string; status: 'started'; thread_id: string }
18
+ | {
19
+ agent: string
20
+ status: 'refused' | 'failed'
21
+ reason: string
22
+ attempt_id?: string
23
+ }
24
+ export interface StartTasksOutput {
25
+ results: StartTaskResult[]
26
+ notify: 'caller' | 'none'
27
+ /** Stated at the point of decision, where the model actually reads it. */
28
+ delivery: string
29
+ }
30
+ export interface CompleteTaskInput {
31
+ summary: string
32
+ }
33
+ export interface CompleteTaskOutput {
34
+ status: 'recorded' | 'already_recorded' | 'refused'
35
+ reason?: string
36
+ }
37
+ /** What this session is, so the surface can gate itself instead of refusing later. */
38
+ export interface TaskRole {
39
+ is_task_assignee: boolean
40
+ can_start_task_threads: boolean
41
+ }
42
+ export interface TaskActions {
43
+ startTasks(caller: TaskCallerRef, input: StartTasksInput): Promise<StartTasksOutput>
44
+ completeTask(caller: TaskCallerRef, input: CompleteTaskInput): Promise<CompleteTaskOutput>
45
+ describeRole(caller: TaskCallerRef): Promise<TaskRole>
46
+ }
47
+ /** One in-thread handoff inside a delegated task. */
48
+ export type InvocationState = 'outstanding' | 'returned' | 'cancelled'
49
+ export interface InvocationRecord {
50
+ invocationId: string
51
+ taskId: string
52
+ callerAgent: string
53
+ callerSessionKey: string
54
+ calleeAgent: string
55
+ callEventId?: string
56
+ calleeSessionKey?: string
57
+ state: InvocationState
58
+ }
59
+ /** Open human-input requests for a session. ZOD078 supplies the implementation. */
60
+ export interface PendingInputRegistry {
61
+ countFor(sessionKey: string): number
62
+ cancelFor(sessionKeys: string[]): void
63
+ }
64
+ export const NO_PENDING_INPUT: PendingInputRegistry = {
65
+ countFor: () => 0,
66
+ cancelFor: () => {},
67
+ }
68
+ export const THREAD_START_FIELD = 'dev.zooid.thread_start'
69
+ export const THREAD_RESULT_FIELD = 'dev.zooid.thread_result'
70
+ export interface ThreadStartContent {
71
+ version: 1
72
+ assignee: string
73
+ attempt_id: string
74
+ parent: { agent: string; thread_root: string; session_key: string }
75
+ notify: 'caller' | 'none'
76
+ }
77
+ export interface ThreadCompletion {
78
+ agent: string
79
+ thread_id: string
80
+ status: 'complete' | 'failed' | 'cancelled' | 'partial'
81
+ output?: { type: 'message'; text: string }
82
+ reason?: string
83
+ error?: string
84
+ }