Conditional Logic

Most agent logic is a decision: route a request, accept or reject an input, pick which model to ask. AJS has if/else, else if, the ternary, and &&, || and ??, and they behave as they do in JavaScript, with three differences listed after this first example.

function checkAge({ age = 25 }) {
  if (age >= 18) {
    let status = 'adult'
    return { status, canVote: true }
  } else {
    let status = 'minor'
    return { status, canVote: false }
  }
}

An agent's one parameter is the arguments object the host passes, as in vm.run(ast, { age: 12 }), and the object it returns is the run's result. On this site each example runs with no arguments; Hello World shows the host side.

With no arguments this returns { status: 'adult', canVote: true }; with { age: 12 } it returns { status: 'minor', canVote: false }.

What to notice

The three differences from JavaScript:

A variation

A grade from a score, with an else if chain, a ternary, and a lookup table read with a computed key:

function grade({ score = 72 }) {
  let letter = 'F'
  if (score >= 90) {
    letter = 'A'
  } else if (score >= 80) {
    letter = 'B'
  } else if (score >= 70) {
    letter = 'C'
  }
  const notes = { A: 'excellent', B: 'good', C: 'fair', F: 'see me' }
  const note = notes[letter]
  const result = score >= 60 ? 'pass' : 'fail'
  return { letter, note, result }
}

The default returns { letter: 'C', note: 'fair', result: 'pass' }. Assigning to letter inside a branch changes the letter declared above the if.

Conditions combine with && and ||. Here a support ticket is routed by its text and the customer's tier:

function route({ text = 'refund my order', tier = 'free' }) {
  const lower = text.toLowerCase()
  let queue = 'general'
  if (lower.includes('refund') || lower.includes('chargeback')) {
    queue = 'billing'
  } else if (lower.includes('password')) {
    queue = 'account'
  }
  if (tier == 'enterprise' && queue != 'general') {
    return { queue, priority: 'high' }
  }
  return { queue, priority: 'normal' }
}

The default returns { queue: 'billing', priority: 'normal' }; with { text: 'Reset PASSWORD', tier: 'enterprise' } it returns { queue: 'account', priority: 'high' }.

toLowerCase and includes are string methods AJS allows; parseInt and Number.isNaN, used below, are builtins it allows. The VM calls only methods and builtins on its list, which is in Methods and Expression Builtins.

A lookup instead of a switch

Where JavaScript would switch on a value, an AJS agent can look the value up in an object:

function dispatch({ kind = 'refund' }) {
  const queues = { refund: 'billing', password: 'account' }
  let queue = 'general'
  if (Object.keys(queues).includes(kind)) {
    queue = queues[kind]
  }
  return { queue }
}

The default returns { queue: 'billing' }, { kind: 'password' } returns { queue: 'account' }, and { kind: 'shipping' } returns { queue: 'general' }.

The Object.keys check is there because kind comes from outside. Without it, { kind: 'toString' } would read a method rather than a missing key, and { kind: 'constructor' } a forbidden property; both end the run with an error. Checking the table's own keys first means any other string falls through to 'general'.

Falsy values and fallbacks

Truthiness is JavaScript's: the falsy values are false, 0, -0, '', null, undefined and NaN. That matters when you pick a fallback. || replaces any falsy value, ?? only null and undefined:

function pickLimit({ requested = 0 }) {
  const viaOr = requested || 10
  const viaNullish = requested ?? 10
  return { viaOr, viaNullish }
}

This returns { viaOr: 10, viaNullish: 0 }: if 0 is a real answer, use ??.

The gotcha

== does not coerce, but <, >, <= and >= compare the way JavaScript does: a numeric string against a number is compared as numbers, so '18' >= 18 is true while '18' == 18 is false. Two strings are compared as text, so '9' < '10' is false. A string is a primitive, so the operator rule above allows both.

The input contract keeps strings out of a number parameter, so this bites on values that arrive as text inside the agent: a form field, a model's answer, a field of parsed JSON. Convert once, check that the conversion worked, and compare numbers, so == and >= agree. Here the default '17' makes age a string parameter on purpose, standing in for such a value:

function checkAgeFromText({ age = '17' }) {
  const years = parseInt(age, 10)
  if (Number.isNaN(years) || `${years}` != age) {
    return { status: 'unknown', canVote: false }
  }
  return { status: years >= 18 ? 'adult' : 'minor', canVote: years >= 18 }
}

The default returns { status: 'minor', canVote: false }, { age: '18' } returns { status: 'adult', canVote: true }, and { age: 'abc' }, { age: '18abc' } and { age: '17.9' } all return { status: 'unknown', canVote: false }.

Both checks are needed. parseInt reads leading digits and stops, so '18abc' becomes 18 and '17.9' becomes 17; writing the number back as text and comparing it with the input rejects anything that is not exactly a whole number. (Number(s) is not a function in AJS.) Without the Number.isNaN check, 'abc' would fall through as NaN, and every ordering comparison (<, >=, …) with NaN is false, so it would silently read as a minor. Number.isNaN is the clear way to test for NaN, whatever == does with it.