@sweny-ai/core 0.1.105 → 0.1.107

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/dist/executor.js CHANGED
@@ -625,18 +625,50 @@ function resolveSkillInstructions(skillIds, skills) {
625
625
  * `options.env ?? process.env` so an explicit env map is honored consistently
626
626
  * with Source resolution (which reads the same map). Defaults to
627
627
  * `process.env` when omitted.
628
+ *
629
+ * Presence vs. truthiness: an explicitly-provided empty string is treated as
630
+ * present, not absent. Resolution rules:
631
+ * - An override that is present (any string, including "") wins; the env
632
+ * var is not consulted. An explicit "" override does NOT silently fall
633
+ * through to env.
634
+ * - Otherwise the env var (when declared) is used.
635
+ * - A value that is `undefined` (no override, no env var) is absent: a
636
+ * required field reports "not provided".
637
+ * - A value that is "" is present-but-empty: a required field reports a
638
+ * distinct "set to empty" message (you set it, but to an unusable value),
639
+ * while an optional field passes the empty string through.
628
640
  */
629
641
  function resolveConfig(skills, overrides, env = process.env) {
630
642
  const config = {};
631
643
  const missing = [];
632
644
  for (const skill of skills.values()) {
633
645
  for (const [key, field] of Object.entries(skill.config)) {
634
- const value = overrides?.[key] ?? (field.env ? env[field.env] : undefined);
635
- if (value) {
636
- config[key] = value;
646
+ // Explicit override wins, including an empty string — `in` distinguishes
647
+ // a present "" from an absent key, so an override never falls through to
648
+ // env in a surprising way.
649
+ const hasOverride = overrides != null && key in overrides;
650
+ const value = hasOverride ? overrides[key] : field.env ? env[field.env] : undefined;
651
+ const source = field.env ? ` (env: ${field.env})` : "";
652
+ const label = `${skill.id}.${key}${source || " (env: none)"}`;
653
+ if (value === undefined) {
654
+ // Truly absent: no override and no env value.
655
+ if (field.required) {
656
+ missing.push(`${label}: not provided`);
657
+ }
637
658
  }
638
- else if (field.required) {
639
- missing.push(`${skill.id}.${key} (env: ${field.env ?? "none"})`);
659
+ else if (value === "") {
660
+ // Present but empty. An empty credential is unusable for a required
661
+ // field; surface a distinct message so the user can tell "set to empty"
662
+ // from "forgot it". Optional empty values pass through unchanged.
663
+ if (field.required) {
664
+ missing.push(`${label}: set to empty`);
665
+ }
666
+ else {
667
+ config[key] = value;
668
+ }
669
+ }
670
+ else {
671
+ config[key] = value;
640
672
  }
641
673
  }
642
674
  }
@@ -710,6 +742,23 @@ async function resolveNext(workflow, current, results, input, claude, observer,
710
742
  // Check for a default (unconditional) edge among conditionals
711
743
  const defaultEdge = outEdges.find((e) => !e.when);
712
744
  const conditionalEdges = outEdges.filter((e) => e.when);
745
+ // Defense-in-depth for AMBIGUOUS_EDGES (rejected by validateWorkflow): with
746
+ // no conditional edges there is nothing for the route evaluator to decide.
747
+ // Follow the (first) unconditional edge directly and skip the no-op
748
+ // claude.evaluate call. validateWorkflow already rejects 2+ unconditional
749
+ // edges, so a well-formed workflow reaching here has exactly one default.
750
+ if (conditionalEdges.length === 0 && defaultEdge) {
751
+ if (outEdges.length > 1) {
752
+ logger?.warn(` route eval: node '${current}' has ${outEdges.length} unconditional out-edges; following '${defaultEdge.to}' ` +
753
+ `and ignoring the rest. Add 'when' clauses to make routing deterministic (see AMBIGUOUS_EDGES).`, { node: current });
754
+ }
755
+ if (edgeCounts) {
756
+ const key = `${current}→${defaultEdge.to}`;
757
+ edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
758
+ }
759
+ safeObserve(observer, { type: "route", from: current, to: defaultEdge.to, reason: "only path" }, logger);
760
+ return defaultEdge.to;
761
+ }
713
762
  // Claude evaluates which condition matches. Include input so conditions
714
763
  // can reference workflow-level flags like dryRun, and expose evals so
715
764
  // routing edges can read `priorNode.evals.X.pass`.
@@ -724,7 +773,14 @@ async function resolveNext(workflow, current, results, input, claude, observer,
724
773
  for (const [k, v] of results.entries()) {
725
774
  const { view, missing } = buildRouteEvalEntry(v, workflow.nodes[k]);
726
775
  context[k] = view;
727
- if (missing.length > 0) {
776
+ // Only a successful node has an output contract to violate. A node that
777
+ // was skipped (requires not met) or failed before producing output never
778
+ // emitted its declared properties; its data is { skipped_reason } / { error }.
779
+ // Warning that those nodes are "missing declared properties" is misleading
780
+ // noise — it alarms operators and pollutes telemetry on every skip. Still
781
+ // build the view above so routing can read the skipped/failed data; only
782
+ // suppress the warning + node:warning for non-success nodes.
783
+ if (missing.length > 0 && v.status === "success") {
728
784
  // Loud signal for the operator. The view itself already replaces the
729
785
  // missing keys with explicit `null`, so the LLM evaluator cannot
730
786
  // ghost-match "is N" or "is undefined" against a structurally-absent
package/dist/schema.d.ts CHANGED
@@ -17,7 +17,7 @@ export declare const configFieldZ: z.ZodObject<{
17
17
  description: z.ZodString;
18
18
  required: z.ZodOptional<z.ZodBoolean>;
19
19
  env: z.ZodOptional<z.ZodString>;
20
- }, "strip", z.ZodTypeAny, {
20
+ }, "strict", z.ZodTypeAny, {
21
21
  description: string;
22
22
  required?: boolean | undefined;
23
23
  env?: string | undefined;
@@ -30,12 +30,14 @@ export declare const configFieldZ: z.ZodObject<{
30
30
  * Zod schema for tool metadata (name, description, input_schema).
31
31
  * Note: `handler` is intentionally omitted — Zod schemas validate
32
32
  * serializable data (JSON import/export), not runtime function refs.
33
+ *
34
+ * .strict() to match the published Tool `$def`'s `additionalProperties: false`.
33
35
  */
34
36
  export declare const toolZ: z.ZodObject<{
35
37
  name: z.ZodString;
36
38
  description: z.ZodString;
37
39
  input_schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
38
- }, "strip", z.ZodTypeAny, {
40
+ }, "strict", z.ZodTypeAny, {
39
41
  description: string;
40
42
  name: string;
41
43
  input_schema: Record<string, unknown>;
@@ -179,7 +181,7 @@ export declare const skillZ: z.ZodEffects<z.ZodObject<{
179
181
  description: z.ZodString;
180
182
  required: z.ZodOptional<z.ZodBoolean>;
181
183
  env: z.ZodOptional<z.ZodString>;
182
- }, "strip", z.ZodTypeAny, {
184
+ }, "strict", z.ZodTypeAny, {
183
185
  description: string;
184
186
  required?: boolean | undefined;
185
187
  env?: string | undefined;
@@ -192,7 +194,7 @@ export declare const skillZ: z.ZodEffects<z.ZodObject<{
192
194
  name: z.ZodString;
193
195
  description: z.ZodString;
194
196
  input_schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
195
- }, "strip", z.ZodTypeAny, {
197
+ }, "strict", z.ZodTypeAny, {
196
198
  description: string;
197
199
  name: string;
198
200
  input_schema: Record<string, unknown>;
@@ -2623,7 +2625,7 @@ export declare function parseWorkflow(raw: unknown): {
2623
2625
  }> | undefined;
2624
2626
  };
2625
2627
  export interface WorkflowError {
2626
- code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "UNBOUNDED_CYCLE" | "UNSUPPORTED_EVAL_POLICY" | "INVALID_INLINE_SKILL";
2628
+ code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "UNBOUNDED_CYCLE" | "AMBIGUOUS_EDGES" | "UNSUPPORTED_EVAL_POLICY" | "INVALID_INLINE_SKILL";
2627
2629
  message: string;
2628
2630
  nodeId?: string;
2629
2631
  }
@@ -3402,6 +3404,11 @@ export declare const skillJsonSchema: {
3402
3404
  readonly McpServerConfig: {
3403
3405
  readonly type: "object";
3404
3406
  readonly description: "External MCP server definition.";
3407
+ readonly anyOf: readonly [{
3408
+ readonly required: readonly ["command"];
3409
+ }, {
3410
+ readonly required: readonly ["url"];
3411
+ }];
3405
3412
  readonly additionalProperties: false;
3406
3413
  readonly properties: {
3407
3414
  readonly type: {
package/dist/schema.js CHANGED
@@ -14,21 +14,32 @@ export { sourceZ };
14
14
  export { workflowInputsZ };
15
15
  // ─── Zod Schemas ─────────────────────────────────────────────────
16
16
  export const jsonSchemaZ = z.record(z.unknown());
17
- export const configFieldZ = z.object({
17
+ // .strict() to match the published skill JSON Schema's ConfigField
18
+ // `additionalProperties: false`. Without it Zod silently STRIPPED an unknown
19
+ // nested key while ajv REJECTED it (the #214/#225 divergence class, missed for
20
+ // configFieldZ/toolZ). custom-loader.ts builds Skill objects from frontmatter
21
+ // and never runs these schemas, so tightening is safe downstream.
22
+ export const configFieldZ = z
23
+ .object({
18
24
  description: z.string(),
19
25
  required: z.boolean().optional(),
20
26
  env: z.string().optional(),
21
- });
27
+ })
28
+ .strict();
22
29
  /**
23
30
  * Zod schema for tool metadata (name, description, input_schema).
24
31
  * Note: `handler` is intentionally omitted — Zod schemas validate
25
32
  * serializable data (JSON import/export), not runtime function refs.
33
+ *
34
+ * .strict() to match the published Tool `$def`'s `additionalProperties: false`.
26
35
  */
27
- export const toolZ = z.object({
36
+ export const toolZ = z
37
+ .object({
28
38
  name: z.string().min(1),
29
39
  description: z.string(),
30
40
  input_schema: jsonSchemaZ,
31
- });
41
+ })
42
+ .strict();
32
43
  export const skillCategoryZ = z.enum(SKILL_CATEGORIES);
33
44
  export const mcpServerConfigZ = z
34
45
  .object({
@@ -344,6 +355,32 @@ export function validateWorkflow(workflow, knownSkills) {
344
355
  });
345
356
  }
346
357
  }
358
+ // Edge determinism: a node with two or more out-edges that have no `when`
359
+ // clause is ambiguous. The executor's resolveNext picks the first such edge
360
+ // and silently drops the rest, wasting a route eval on a deterministic hop
361
+ // and leaving the other targets unreachable. Reject it at load time so the
362
+ // author sees the problem instead of getting a silent first-edge-wins.
363
+ // Only count edges whose source is a real node (broken sources are already
364
+ // reported above as UNKNOWN_EDGE_SOURCE).
365
+ const unconditionalByNode = new Map();
366
+ for (const edge of workflow.edges) {
367
+ if (!nodeIds.has(edge.from))
368
+ continue;
369
+ if (edge.when)
370
+ continue;
371
+ const list = unconditionalByNode.get(edge.from) ?? [];
372
+ list.push(edge.to);
373
+ unconditionalByNode.set(edge.from, list);
374
+ }
375
+ for (const [from, targets] of unconditionalByNode) {
376
+ if (targets.length > 1) {
377
+ errors.push({
378
+ code: "AMBIGUOUS_EDGES",
379
+ message: `Node "${from}" has ${targets.length} unconditional out-edges (to ${targets.join(", ")}); add a 'when' clause to all but one so routing is deterministic`,
380
+ nodeId: from,
381
+ });
382
+ }
383
+ }
347
384
  // Don't check reachability if there are structural errors
348
385
  if (errors.length > 0)
349
386
  return errors;
@@ -1009,6 +1046,11 @@ export const skillJsonSchema = {
1009
1046
  McpServerConfig: {
1010
1047
  type: "object",
1011
1048
  description: "External MCP server definition.",
1049
+ // An MCP server must declare command (stdio) or url (http), mirroring
1050
+ // mcpServerConfigZ.refine(c => c.command || c.url) and the workflow
1051
+ // inline-skill mcp block. Without this the published skill.json accepted a
1052
+ // transport-less mcp block the runtime parser rejects (Zod<->ajv drift).
1053
+ anyOf: [{ required: ["command"] }, { required: ["url"] }],
1012
1054
  additionalProperties: false,
1013
1055
  properties: {
1014
1056
  type: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.105",
3
+ "version": "0.1.107",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"