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
10 changes: 7 additions & 3 deletions packages/agent/src/local_agent/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1240,9 +1240,11 @@ fn def_create_schema() -> ToolDefinition {
"targetType": { "type": "string", "description": "Target node type ID — MUST be an existing type from the EXISTING SCHEMAS list (e.g., 'task', 'ticket', 'adr'). Do NOT invent types that don't exist yet." },
"direction": { "type": "string", "enum": ["out", "in"], "description": "Direction: 'out' (this→target, default) or 'in' (target→this)" },
"cardinality": { "type": "string", "enum": ["one", "many"], "description": "Cardinality: 'one' or 'many' (default)" },
"reverseName": { "type": "string", "description": "REQUIRED. What this edge is called read from the target's end — one edge is stored and read from both sides, so it needs a name from each. Choose it: plural where the target may hold many (an invoice billed_to a customer reads back as 'invoices', NOT 'Invoice (Customer)')." },
"reverseCardinality": { "type": "string", "enum": ["one", "many"], "description": "REQUIRED. How many sources may point at one target: 'one' or 'many'. An invoice billed_to one customer, where a customer may have many invoices, is cardinality 'one' with reverseCardinality 'many'." },
"description": { "type": "string", "description": "What this relationship represents" }
},
"required": ["name", "targetType", "direction", "cardinality"]
"required": ["name", "targetType", "direction", "cardinality", "reverseName", "reverseCardinality"]
}
}
},
Expand Down Expand Up @@ -1319,9 +1321,11 @@ fn def_update_schema() -> ToolDefinition {
"name": { "type": "string" },
"targetType": { "type": "string" },
"direction": { "type": "string", "enum": ["out", "in"] },
"cardinality": { "type": "string", "enum": ["one", "many"] }
"cardinality": { "type": "string", "enum": ["one", "many"] },
"reverseName": { "type": "string", "description": "REQUIRED. What this edge is called read from the target's end — one edge is stored and read from both sides, so it needs a name from each. Choose it: plural where the target may hold many (an invoice billed_to a customer reads back as 'invoices', NOT 'Invoice (Customer)')." },
"reverseCardinality": { "type": "string", "enum": ["one", "many"], "description": "REQUIRED. How many sources may point at one target: 'one' or 'many'. An invoice billed_to one customer, where a customer may have many invoices, is cardinality 'one' with reverseCardinality 'many'." }
},
"required": ["name", "targetType", "direction", "cardinality"]
"required": ["name", "targetType", "direction", "cardinality", "reverseName", "reverseCardinality"]
}
},
"remove_relationships": {
Expand Down
8 changes: 4 additions & 4 deletions packages/agent/src/skill_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ pub const RELATIONSHIP_VS_FIELD: SchemaRule = SchemaRule {

pub const TARGET_TYPE_MUST_EXIST: SchemaRule = SchemaRule {
id: "target-type-must-exist",
imperative: "The targetType MUST be an existing schema ID from the EXISTING SCHEMAS list in the system prompt, or the schema ID of the type you are creating in this same call — do NOT invent types that aren't listed. If the target type doesn't exist yet, omit the relationship entirely. Set reverseName when the edge has a natural name read from the target's end — it makes the reverse query directly callable. Examples:\n- ADR supersedes adr (one): `{\"name\": \"supersedes\", \"targetType\": \"adr\", \"direction\": \"out\", \"cardinality\": \"one\"}`\n- Ticket has_task task (many): `{\"name\": \"has_task\", \"targetType\": \"task\", \"direction\": \"out\", \"cardinality\": \"many\"}`\n- ADR decided_by person, readable back as the person's decisions: `{\"name\": \"decided_by\", \"targetType\": \"person\", \"direction\": \"out\", \"cardinality\": \"one\", \"reverseName\": \"decisions\", \"reverseCardinality\": \"many\"}`\n\nSELF-REFERENCE: a type may point at itself in the same schema create call — use its own schema ID (the snake_case form of the name), no second call needed. Give the reverse direction a reverseName rather than declaring a second relationship: one stored edge, readable from both ends. Example, on `schema create` for ADR: `{\"name\": \"supersedes\", \"targetType\": \"adr\", \"direction\": \"out\", \"cardinality\": \"one\", \"reverseName\": \"superseded_by\", \"reverseCardinality\": \"one\"}`. Same for `blocks`/`blocked_by` on a task or `parent`/`child` on a category.",
prose: "`targetType` must be an existing schema ID, or the schema ID of the type being created in the same call. Set `reverseName` when the edge has a natural name read from the target's end — that name becomes directly queryable from that side. Examples: `{\"name\":\"supersedes\",\"targetType\":\"adr\",\"direction\":\"out\",\"cardinality\":\"one\"}`, `{\"name\":\"has_task\",\"targetType\":\"task\",\"direction\":\"out\",\"cardinality\":\"many\"}`, `{\"name\":\"decided_by\",\"targetType\":\"person\",\"direction\":\"out\",\"cardinality\":\"one\",\"reverseName\":\"decisions\",\"reverseCardinality\":\"many\"}`.\n\n**Self-referential relationships:** a type may point at itself in the same `schema create` call — give its own schema ID (the snake_case form of the name); no follow-up `schema update` is needed. Name the reverse direction with `reverseName` rather than declaring a second relationship, so one stored edge is readable from both ends: `{\"name\":\"supersedes\",\"targetType\":\"adr\",\"direction\":\"out\",\"cardinality\":\"one\",\"reverseName\":\"superseded_by\",\"reverseCardinality\":\"one\"}`. The same shape covers `blocks`/`blocked_by` on a task and `parent`/`child` on a category.",
imperative: "The targetType MUST be an existing schema ID from the EXISTING SCHEMAS list in the system prompt, or the schema ID of the type you are creating in this same call — do NOT invent types that aren't listed. If the target type doesn't exist yet, omit the relationship entirely. reverseName and reverseCardinality are REQUIRED on every relationship — a declaration missing either is rejected. One edge is stored and read from BOTH ends, so name it from both: reverseName is what the edge is called read from the target (plural where that end may hold many — \"invoices\", not \"Invoice (Customer)\"), and reverseCardinality is \"one\" or \"many\", saying how many sources may point at one target. Examples:\n- ADR supersedes adr (one), read back as superseded_by: `{\"name\": \"supersedes\", \"targetType\": \"adr\", \"direction\": \"out\", \"cardinality\": \"one\", \"reverseName\": \"superseded_by\", \"reverseCardinality\": \"one\"}`\n- Ticket has_task task (many), read back as ticket: `{\"name\": \"has_task\", \"targetType\": \"task\", \"direction\": \"out\", \"cardinality\": \"many\", \"reverseName\": \"ticket\", \"reverseCardinality\": \"one\"}`\n- ADR decided_by person, readable back as the person's decisions: `{\"name\": \"decided_by\", \"targetType\": \"person\", \"direction\": \"out\", \"cardinality\": \"one\", \"reverseName\": \"decisions\", \"reverseCardinality\": \"many\"}`\n\nSELF-REFERENCE: a type may point at itself in the same schema create call — use its own schema ID (the snake_case form of the name), no second call needed. The required reverseName is what names the other direction, so never declare a second relationship for it: one stored edge, readable from both ends. Example, on `schema create` for ADR: `{\"name\": \"supersedes\", \"targetType\": \"adr\", \"direction\": \"out\", \"cardinality\": \"one\", \"reverseName\": \"superseded_by\", \"reverseCardinality\": \"one\"}`. Same for `blocks`/`blocked_by` on a task or `parent`/`child` on a category.",
prose: "`targetType` must be an existing schema ID, or the schema ID of the type being created in the same call. `reverseName` and `reverseCardinality` are **required** on every relationship — a declaration missing either is rejected. One edge is stored and read from both ends, so name it from both: `reverseName` is what the edge is called read from the target (plural where that end may hold many — `invoices`, not `Invoice (Customer)`), and `reverseCardinality` is `one` or `many`, saying how many sources may point at one target. Examples: `{\"name\":\"supersedes\",\"targetType\":\"adr\",\"direction\":\"out\",\"cardinality\":\"one\",\"reverseName\":\"superseded_by\",\"reverseCardinality\":\"one\"}`, `{\"name\":\"has_task\",\"targetType\":\"task\",\"direction\":\"out\",\"cardinality\":\"many\",\"reverseName\":\"ticket\",\"reverseCardinality\":\"one\"}`, `{\"name\":\"decided_by\",\"targetType\":\"person\",\"direction\":\"out\",\"cardinality\":\"one\",\"reverseName\":\"decisions\",\"reverseCardinality\":\"many\"}`.\n\n**Self-referential relationships:** a type may point at itself in the same `schema create` call — give its own schema ID (the snake_case form of the name); no follow-up `schema update` is needed. The required `reverseName` is what names the other direction, so never declare a second relationship for it — one stored edge, readable from both ends: `{\"name\":\"supersedes\",\"targetType\":\"adr\",\"direction\":\"out\",\"cardinality\":\"one\",\"reverseName\":\"superseded_by\",\"reverseCardinality\":\"one\"}`. The same shape covers `blocks`/`blocked_by` on a task and `parent`/`child` on a category.",
};

pub const ENUM_EDGE_FIELDS: SchemaRule = SchemaRule {
Expand Down Expand Up @@ -233,8 +233,8 @@ pub const ORG_NEEDS_EXISTING_COLLECTION: InteractionRule = InteractionRule {
/// spellings, but the mapping is cheap to state up front.
pub const RELATIONSHIP_REVERSE_TRAVERSAL: InteractionRule = InteractionRule {
id: "relationship-reverse-traversal",
imperative: "REVERSE TRAVERSAL: A relationship declared as {\"name\": \"decided_by\", \"targetType\": \"person\", \"reverseName\": \"decisions\"} on the adr schema is readable from BOTH ends. From an adr, call get_related_nodes with relationship_type 'decided_by' and direction 'out'. From the person, use the declared reverseName — relationship_type 'decisions' — or equivalently 'decided_by' with direction 'in'; both return the same ADRs. An empty result means no edges exist, NOT that the reverse direction is unsupported. A relationship name that is declared in neither direction is now an error naming the spellings that do work — read it and retry rather than reporting the capability as missing.",
prose: "**Traversing the reverse direction.** A relationship is declared once, on the source type, but reads from both ends. Given `{\"name\":\"decided_by\",\"targetType\":\"person\",\"reverseName\":\"decisions\"}` on `adr`: from the ADR, `nodespace relationship get <adr-id> --type decided_by --direction out`; from the person, use the declared `reverseName` — `nodespace relationship get <person-id> --type decisions` — or the equivalent `--type decided_by --direction in`. Both spellings return the same ADRs, and the output line's arrow shows the direction actually traversed. An empty result means no edges exist, not that reverse traversal is unsupported. A name declared in neither direction is rejected with an error naming the spellings that do work — read it and retry rather than concluding the capability is missing.",
imperative: "REVERSE TRAVERSAL: A relationship declared as {\"name\": \"decided_by\", \"targetType\": \"person\", \"direction\": \"out\", \"cardinality\": \"one\", \"reverseName\": \"decisions\", \"reverseCardinality\": \"many\"} on the adr schema is readable from BOTH ends. From an adr, call get_related_nodes with relationship_type 'decided_by' and direction 'out'. From the person, use the declared reverseName — relationship_type 'decisions' — or equivalently 'decided_by' with direction 'in'; both return the same ADRs. An empty result means no edges exist, NOT that the reverse direction is unsupported. A relationship name that is declared in neither direction is now an error naming the spellings that do work — read it and retry rather than reporting the capability as missing.",
prose: "**Traversing the reverse direction.** A relationship is declared once, on the source type, but reads from both ends. Given `{\"name\":\"decided_by\",\"targetType\":\"person\",\"direction\":\"out\",\"cardinality\":\"one\",\"reverseName\":\"decisions\",\"reverseCardinality\":\"many\"}` on `adr`: from the ADR, `nodespace relationship get <adr-id> --type decided_by --direction out`; from the person, use the declared `reverseName` — `nodespace relationship get <person-id> --type decisions` — or the equivalent `--type decided_by --direction in`. Both spellings return the same ADRs, and the output line's arrow shows the direction actually traversed. An empty result means no edges exist, not that reverse traversal is unsupported. A name declared in neither direction is rejected with an error naming the spellings that do work — read it and retry rather than concluding the capability is missing.",
skill_md_key_phrase: "use the declared `reverseName`",
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ RENAME VS RELABEL: rename_fields can rename a field's storage key OR relabel its

DELETING A SCHEMA — the capability exists, use it: When the user asks to remove, drop, undo or clean up a node type — including a throwaway type you created yourself this session — delete it. Do NOT report deletion as unsupported and do NOT propose stripping the schema to an empty shell as a workaround. PREREQUISITE: a schema carrying relationship declarations cannot be deleted; call update_schema with remove_relationships first, on THIS type for the relationships it declares AND on every other type that declares a relationship targeting it, then delete the schema node. This is the mirror of the targetType rule: a relationship's target must EXIST to declare it, and must be ABSENT to delete the type it points at. The rejection names the remaining declaration count — act on it rather than giving up. Only declarations BETWEEN SCHEMAS block the delete: edges between ordinary nodes are instance data and do not count, so do not go hunting for those. Deleting the type does NOT delete its existing instances — they remain as nodes; delete those separately with delete_node if the user wants them gone too.

RELATIONSHIPS: Use relationships (not fields) when a field references another node type. The targetType MUST be an existing schema ID from the EXISTING SCHEMAS list in the system prompt, or the schema ID of the type you are creating in this same call — do NOT invent types that aren't listed. If the target type doesn't exist yet, omit the relationship entirely. Set reverseName when the edge has a natural name read from the target's end — it makes the reverse query directly callable. Examples:
RELATIONSHIPS: Use relationships (not fields) when a field references another node type. The targetType MUST be an existing schema ID from the EXISTING SCHEMAS list in the system prompt, or the schema ID of the type you are creating in this same call — do NOT invent types that aren't listed. If the target type doesn't exist yet, omit the relationship entirely. reverseName and reverseCardinality are REQUIRED on every relationship — a declaration missing either is rejected. One edge is stored and read from BOTH ends, so name it from both: reverseName is what the edge is called read from the target (plural where that end may hold many — "invoices", not "Invoice (Customer)"), and reverseCardinality is "one" or "many", saying how many sources may point at one target. Examples:

ADR supersedes adr (one): `{"name": "supersedes", "targetType": "adr", "direction": "out", "cardinality": "one"}`
ADR supersedes adr (one), read back as superseded_by: `{"name": "supersedes", "targetType": "adr", "direction": "out", "cardinality": "one", "reverseName": "superseded_by", "reverseCardinality": "one"}`

Ticket has_task task (many): `{"name": "has_task", "targetType": "task", "direction": "out", "cardinality": "many"}`
Ticket has_task task (many), read back as ticket: `{"name": "has_task", "targetType": "task", "direction": "out", "cardinality": "many", "reverseName": "ticket", "reverseCardinality": "one"}`

ADR decided_by person, readable back as the person's decisions: `{"name": "decided_by", "targetType": "person", "direction": "out", "cardinality": "one", "reverseName": "decisions", "reverseCardinality": "many"}`

SELF-REFERENCE: a type may point at itself in the same schema create call — use its own schema ID (the snake_case form of the name), no second call needed. Give the reverse direction a reverseName rather than declaring a second relationship: one stored edge, readable from both ends. Example, on `schema create` for ADR: `{"name": "supersedes", "targetType": "adr", "direction": "out", "cardinality": "one", "reverseName": "superseded_by", "reverseCardinality": "one"}`. Same for `blocks`/`blocked_by` on a task or `parent`/`child` on a category.
SELF-REFERENCE: a type may point at itself in the same schema create call — use its own schema ID (the snake_case form of the name), no second call needed. The required reverseName is what names the other direction, so never declare a second relationship for it: one stored edge, readable from both ends. Example, on `schema create` for ADR: `{"name": "supersedes", "targetType": "adr", "direction": "out", "cardinality": "one", "reverseName": "superseded_by", "reverseCardinality": "one"}`. Same for `blocks`/`blocked_by` on a task or `parent`/`child` on a category.

TITLE TEMPLATE: Set title_template when a node's identity comes from its fields rather than free-form content. Use {field_name} placeholders. CRITICAL: every placeholder in title_template MUST be defined as a field in the fields array. Omit title_template if the content/title field alone identifies the node.

Expand Down
Loading